diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 751e7440f0d..ebaf683d12f 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,5 +1,6 @@
# These are supported funding model platforms
-github: westnordost
-liberapay: westnordost
-patreon: westnordost
+# maybe show both?
+#github: westnordost
+liberapay: Helium314
+#patreon: westnordost
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index f4868bf95fb..72e90cbb54e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -6,9 +6,14 @@ labels: bug
---
**How to Reproduce**
@@ -17,5 +22,8 @@ Maybe it is not a bug? Check the FAQ: https://wiki.openstreetmap.org/wiki/Street
**Expected Behavior**
+**Does it happen in normal StreetComplete?**
+
+
**Versions affected**
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 597e5528e99..2cda0da4545 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,8 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Discussions
- url: https://github.com/streetcomplete/StreetComplete/discussions
- about: For discussions about the usage of StreetComplete, asking questions or talking about ideas which are not yet actionable.
+ url: https://github.com/Helium314/SCEE/discussions
+ about: For discussions about the usage of SCEE, asking questions or talking about ideas which are not yet actionable.
- name: Translations
- url: https://github.com/streetcomplete/StreetComplete/blob/master/CONTRIBUTING.md#translating-the-app
- about: For contributing and discussing translations of StreetComplete
+ url: https://github.com/Helium314/SCEE#translations
+ about: For contributing and discussing translations of SCEE
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index a8ea2366599..10c85578de6 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -3,6 +3,14 @@ name: Feature request
about: Suggest an idea for this project
---
+
+
**Use case**
diff --git a/.github/ISSUE_TEMPLATE/quest-suggestion.md b/.github/ISSUE_TEMPLATE/quest-suggestion.md
index 1ea5595065b..7a3eaa3cd11 100644
--- a/.github/ISSUE_TEMPLATE/quest-suggestion.md
+++ b/.github/ISSUE_TEMPLATE/quest-suggestion.md
@@ -6,9 +6,13 @@ about: Suggest a new question to ask the user
### General
@@ -17,10 +21,10 @@ Question asked: **Is there an example tag on item XY?**
### Checklist
-Checklist for quest suggestions (see [guidelines](https://github.com/streetcomplete/StreetComplete/blob/master/QUEST_GUIDELINES.md)):
+Checklist for quest suggestions (see [guidelines](https://github.com/Helium314/SCEE/blob/modified/QUEST_GUIDELINES.md)):
- [ ] 🚧 To be added tag is established and has a useful purpose
-- [ ] 🤔 Any answer the user can give must have an equivalent tagging (Quest should not reappear to other users when solved by one)
-- [ ] 🐿️ Easily answerable by any pedestrian from the outside but a survey is necessary
+- [ ] 🤔 Any answer the user can give should have an equivalent tagging (SCEE quests may not be answerable in all cases, but please keep this rare)
+- [ ] 🐿️ A survey is necessary (may require knowledge of the subject, though easy answering for everyone is preferred)
- [ ] 💤 Not an overwhelming percentage of quests have the same answer (No spam)
- [ ] 🕓 Applies to a reasonable number of map data (Worth the effort)
diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml
index 366038f53ae..c7fa0da94c1 100644
--- a/.github/workflows/build-debug-apk.yml
+++ b/.github/workflows/build-debug-apk.yml
@@ -31,7 +31,7 @@ jobs:
run: ./gradlew assembleDebug
- name: Rename APK
- run: mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/StreetComplete-debug-$COMMIT_SHA.apk
+ run: mv app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/SCEE-debug-$COMMIT_SHA.apk
- name: Archive APK
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/build-signed-apk.yml b/.github/workflows/build-signed-apk.yml
new file mode 100644
index 00000000000..77bda72560b
--- /dev/null
+++ b/.github/workflows/build-signed-apk.yml
@@ -0,0 +1,36 @@
+name: Build signed apk
+
+on:
+ [workflow_dispatch]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Create keystore file
+ run: echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
+ - name: Build with Gradle
+ run: ./gradlew assembleRelease
+ -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks
+ -Pandroid.injected.signing.store.password=${{ secrets.keystore_password }}
+ -Pandroid.injected.signing.key.alias=${{ secrets.key_alias }}
+ -Pandroid.injected.signing.key.password=${{ secrets.key_password }}
+ - name: Rename APK
+ run: mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/SCEE-release-$(git log -n 1 --format='%h').apk
+ - name: Archive APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-apk
+ path: app/build/outputs/apk/release/*.apk
+ retention-days: 14
diff --git a/.github/workflows/generate-quest-list.yml b/.github/workflows/generate-quest-list.yml
index 4e6cc4f41af..885c28c0c8f 100644
--- a/.github/workflows/generate-quest-list.yml
+++ b/.github/workflows/generate-quest-list.yml
@@ -1,9 +1,6 @@
name: Generate quest list
on:
- push:
- branches:
- - master
workflow_dispatch:
jobs:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index fd225a326ef..301e2c58e21 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,9 +1,6 @@
name: "Lint"
on:
- push:
- branches:
- - "master"
workflow_dispatch:
jobs:
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 79096b13f86..00000000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 0f7bc519db6..00000000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/.idea/icon.svg b/.idea/icon.svg
index cd7c8311622..2b858bc3e7a 100644
--- a/.idea/icon.svg
+++ b/.idea/icon.svg
@@ -1,2 +1,2 @@
-
\ No newline at end of file
+
diff --git a/.idea/ktlint.xml b/.idea/ktlint.xml
deleted file mode 100644
index 92c444169bb..00000000000
--- a/.idea/ktlint.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
- false
-
-
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 58060bad821..ca70a4f9547 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,21 +16,15 @@ Content:
## Translating the app
-You can translate StreetComplete at POEditor. You can add missing translations and improve existing ones. Discuss translations at POEditor.
+You can translate StreetComplete at POEditor, and SCEE in Weblate. You can add missing translations and improve existing ones.
The only required skills here are ability to read English text and write in your own language.
-Follow [**this link** to improve the translations](https://poeditor.com/join/project/IE4GC127Ki):
+Follow [**this link**](https://translate.codeberg.org/projects/scee/) to improve the translations only available in SCEE, or click the image below to improve translations for StreetComplete (which are also used in SCEE):
[](https://poeditor.com/join/project/IE4GC127Ki)
-After joining, the [main site of the POEditor](https://poeditor.com/projects/) should list StreetComplete for logged in users.
-
-Before each [release](res/documentation/creating%20new%20release.md), translations are pulled in from POEditor. Please, use POEditor for translating. Manual changes submitted as Pull Requests will not be merged as they do not help the project.
-
-Once 100% or close to 100% of text is translated, the given language becomes enabled. Translations which are not maintained are removed. Typically languages which are less than 60% translated will be considered as not maintained and such translation will be disabled.
-
-The source translation is in US English (in [app/src/main/res/values/strings.xml](app/src/main/res/values/strings.xml)). POEditor has a list of modifications in dialects such as UK English and Australian English, listed as separate languages.
+The source translation is in US English (in [app/src/main/res/values/strings_ee.xml](app/src/main/res/values/strings_ee.xml)).
### iD presets
@@ -42,7 +36,7 @@ For iD preset translation see [their documentation](https://github.com/openstree
As you probably noticed, you can choose *"Cannot answer"* in StreetComplete and thus leave a note on OpenStreetMap.
-You can help with [processing OSM notes opened by StreetComplete users](https://ent8r.github.io/NotesReview/?query=StreetComplete&limit=100&start=true). While processing and solving notes, it may become apparent that there is a systematic problem in that users misunderstand the UI or the wording when solving StreetComplete quests.
+You can help with [processing OSM notes opened by StreetComplete and SCEE users](https://ent8r.github.io/NotesReview/?query=StreetComplete&limit=100&start=true). While processing and solving notes, it may become apparent that there is a systematic problem in that users misunderstand the UI or the wording when solving StreetComplete quests.
If you find such user experience problems, please report them back in the [issue tracker of StreetComplete](https://github.com/streetcomplete/StreetComplete/issues). Do not forget to add links to examples, e.g. the notes StreetComplete mappers submitted.
diff --git a/CONTRIBUTING_A_NEW_QUEST.md b/CONTRIBUTING_A_NEW_QUEST.md
index 9b4d92e9bef..504e7dadb45 100644
--- a/CONTRIBUTING_A_NEW_QUEST.md
+++ b/CONTRIBUTING_A_NEW_QUEST.md
@@ -471,6 +471,8 @@ Please make sure that the images do not take too much disk space. Most useful wa
[GIMP](https://gimp.org/) allows such previews while saving JPG files, and there are also online tools like [squoosh](https://squoosh.app/) which allow for quick visual comparison if you prefer that.
+Please try to keep the images small (consider they already make up more than half of the APK size), and consider using vector graphics instead of photos where it's reasonable.
+
After adding a photo, remember to update [the credits file](app/src/main/res/authors.txt) (different to the one for icons).
## Resurvey
diff --git a/QUEST_GUIDELINES.md b/QUEST_GUIDELINES.md
index 003413e9ddb..790bab48beb 100644
--- a/QUEST_GUIDELINES.md
+++ b/QUEST_GUIDELINES.md
@@ -1,21 +1,22 @@
Do you have an idea for a new quest? Read this!
-## 1. Decide whether the idea works out with StreetComplete Quests
+## 1. Decide whether the idea works out with SCEE Quests
Consider the following:
-### Limitations of StreetComplete Quests
-- 🌟 Only existing elements can be extended, no elements can be added or removed.
+### Limitations of SCEE Quests
+- 🌟 Only existing elements can be extended, but nodes can be moved, removed and added (free-floating or to a single way).
- ✂️ The geometry of elements cannot be changed (except splitting up ways and moving nodes)
-- 🏷️ So, basically: Only tags can be edited
### General guidelines
+- See them as suggestions, not necessarily as requirements.
- ⚛️ **Atomic quests**: Per quest, only **one** thing should need to be answered by the user.
- 🚧 **Established tags only**: No new or unestablished tags should be introduced through StreetComplete. Establishing tags must remain a community process and not be dictated by software implementation.
- 🤷 **Useful purpose**: Especially for tags that are not that well established yet - they should have some application. As by the design of OpenStreetMap, there are countless things that *could* be collected, such as the color of the cycleway, the brightness of street lamps, etc. and sometimes things like these are even documented on the wiki (because it is a wiki, obviously). That does not mean that it makes sense to collect this information (in this app).
- 🕓 **Effort vs impact**: Consider if it is worth the effort when compared to the impact the quest would have. For how many elements would this quest type apply? This point is especially valid if you don't plan to implement a quest suggestion yourself through a PR. A quest to determine the type of building applies to 200 million elements while i.e. a quest to determine what a vending machine is selling applies to less than 1000 elements.
### Users
+- See the guidelines below as suggestions. Not fulfilling any of the guidelines perfectly acceptable for SCEE, with the exception of the *no spam* guideline in case it leads to undesired tag spam.
- 🤔 **No unanswerable quests**: All generated quests need to be actually answerable (no false-positives). This means that any answer given by the user must result in something being tagged. For example, a quest that asks for the website of a place must be able to tag the element somehow if the user answers that the place has no website - otherwise, the next user will be asked the same question. Sometimes, due to the nature of how things are tagged in OSM (such as the one given in the example) it is unfortunately simply not possible to fulfill this.
- 👨💻 **Users are no experts**: No knowledge about OpenStreetMap or any other background knowledge must be necessary
- 🐿️ **Easy answer**: Users are out and about and impatient. A quick, straightforward and clear answer must be possible
@@ -32,6 +33,8 @@ Also, for very detailed information that can be assumed to always have the same
Depending on the quest, this requires some research but is necessary preparational work that can be done without any programming knowledge (but with knowledge of OSM).
+SCEE is also able to generate quests from external sources, so search for specific tags is not required. See e.g. the Osmose quest, which converts Osmose issues into quests.
+
## 3. Design the form
As mentioned, the user interface must leave no space for misunderstandings, it must be concise and quick and easy to use. Also sounds obvious, but you will quickly find out that a balance must be found between covering all the edge cases and designing the form to be as straightforward and clutterless as possible.
diff --git a/README.md b/README.md
index df6060442c3..8bc841a7a92 100644
--- a/README.md
+++ b/README.md
@@ -1,74 +1,197 @@
-
-
-StreetComplete is an easy to use editor of OpenStreetMap data available for Android. It can be used without any OpenStreetMap-specific knowledge. It asks simple questions, with answers directly used to edit and improve OpenStreetMap data. The app is aimed at users who do not know anything about OSM tagging schemes but still want to contribute to OpenStreetMap.
-
-StreetComplete automatically looks for nearby places where a survey is needed and shows them as quest markers on its map. Each of these quests can then be solved on site by answering a simple question. For example, tapping on a marker may show the question "What is the name of this road?", with a text field to answer it.
-More examples are shown in the screenshots below.
-
-The user's answer is automatically processed and uploaded directly into the OSM database. Edits are done in meaningful changesets using the user's OSM account.
-Since the app is meant to be used on a survey, it can be used offline and is
-economic with data usage.
-
-To make the app easy to use, quests are limited to those answerable by asking simple questions.
-
-* See the [latest release notes](https://github.com/streetcomplete/StreetComplete/releases).
+SCEE is a modified version of StreetComplete, aimed at experienced OSM users unhappy about the lack of advanced editing capabilities in normal StreetComplete.
+By default, most of the additional capabilities are disabled. Go through the settings (either in the app or [below](#differences-to-streetcomplete)) for details.
+
+Please be aware that SCEE is not suitable for people used to discarding warning messages without reading!
+Users new to OpenStreetMap are best advised to use [StreetComplete](https://github.com/streetcomplete/StreetComplete).
+
+Functionality added in SCEE is considerably less tested than what you might be used to in StreetComplete, so bugs or unexpected behavior may happen. If you encounter any, please report the issue.
+
+1. [Download](#download-scee)
+2. [Translate](#translations)
+3. [Additional permissions](#permissions)
+4. [Differences to StreetComplete](#differences-to-streetcomplete)
+5. [Contributing quests](#contributing-quests)
+6. [Differences in changesets](#changeset-differences-compared-to-streetcomplete)
+
+[StreetComplete readme](README_StreetComplete.md)
+
+[SCEE FAQ](https://wiki.openstreetmap.org/wiki/SCEE/FAQ)
+
+## Download SCEE
+
+[](https://f-droid.org/packages/de.westnordost.streetcomplete.expert/)
+[](https://github.com/Helium314/SCEE/releases/latest)
+
+F-Droid releases of SCEE make use of reproducible builds, so releases on F-Droid and GitHub are signed with the same keys. This means you can switch between GitHub and F-Droid releases anytime without needing to uninstall first.
+
+__F-Droid anti-feature__ _non-free network_: SCEE uses map tiles provided by [jawg](https://www.jawg.io), and optionally [aerial / satellite imagery](https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer) by [Esri](https://www.esri.com) .
+
+## Translations
+Translations for strings added in SCEE can be done [using Weblate at Codeberg](https://translate.codeberg.org/projects/scee/).
+You will need an account to update translations and add languages. Add the language you want to translate to in _Languages_ -> _Manage translated languages_ in the top menu bar.
+
+## Permissions
+SCEE asks for two more permissions than StreetComplete: `ACCESS_BACKGROUND_LOCATION` and `POST_NOTIFICATIONS`. Both are requested and used only in feature to notify about nearby quests while the app is in the background.
+
+## Differences to StreetComplete
+* Non-optional differences to StreetComplete
+ * No star count on main screen
+ * When using auto-upload, an indicator now shows when there are changes waiting to be uploaded
+ * Dark theme uses dark buttons
+ * Prevent short scroll to user location at app start when map was at a different position
+ * Downloading data will interrupt upload queue (will resume afterwards)
+ * Manual downloads can be queued instead of always cancelling the previous one
+ * Additional answers for some quests
+ * Additional building types
+ * Additional path surfaces
+ * Specify that a crossing is raised
+ * Answer non-marked lanes with a count
+ * Answer "no seating, but not takeaway only"
+ * Add wheelchair description when answering wheelchair quest
+ * Move the "no cycleway" answer to more accessible position
+ * Highlight obstacles along the way for smoothness quests
+ * Open settings when pressing menu key in main menu dialog
+ * Allow switching to aerial view while adding or moving a node
+* New quests that are not eligible for StreetComplete, usually because some answers cannot be tagged, or because not everyone has the required knowledge to answer the quest. These quests can only be enabled when expert mode is on.
+ * Material of benches and picnic tables
+ * Phone number and website
+ * Cuisine
+ * Healthcare speciality
+ * Outdoor seating type
+ * Service building type
+ * Service building operator
+ * Street cabinet type
+ * Artwork type
+ * Railway platform number
+ * Subway entrance reference number
+ * Trail visibility of hiking trails
+ * Genus / species of trees
+ * Allows providing a file containing translated tree names instead of the default English ones
+ * Color of building roofs
+ * Whether a barrier is locked
+ * Height of barriers
+ * Whether pharmacy is dispensing prescription drugs
+ * Destination of some road types after intersections
+ * Which beers are sold in restaurants
+ * Elevation, ref, sports and name of guideposts
+ * Width of footways
+ * Size and type of maps
+ * Via ferrata scale
+ * Difficulty and ref for pistes, and whether they are lit
+ * Quests based on external sources
+ * Osmose quest showing Osmose issues as quests, with filter options
+ * Custom quest from CSV file, allows creating nodes (see in-app description)
+ * Show POI quests with the sole purpose of indicating existence of elements of chosen type (may show labels)
+ * Option to show only quests added in SCEE in quest selection menu
+ * Some "other answers" result in a modified changeset comment (because in SCEE they may contain more unexpected changes)
+* Customizable overlays: Choose which elements are highlighted, and which tag is used to determine the color
+* Turn restriction overlay
+* Settings
+ * Additional darker dark theme
+ * Background map can be changed to aerial / satellite imagery
+ * Adjust location update intervals
+ * Expert mode that enables capabilities, some of which can be dangerous when used by inexperienced OSM contributors
+ * Directly edit tags, with suggestions from iD and last used values
+ * Add nodes everywhere, either free-floating or as part of a way
+ * inserting nodes into a way may actually re-use existing nodes at that position
+ * Delete free-floating nodes
+ * Additional "other answers"
+ * add `access=private` to benches, bicycle parkings, picnic tables, pitches, (leisure) tracks and recycling containers
+ * add/adjust highway access
+ * tag highways as under construction (with finish date)
+ * tag buildings as demolished
+ * add conditional maxspeed (maxspeed quest only)
+ * Allow moving nodes that are part of a way (including a clear warning about changing geometry)
+ * Allow disabling and moving the note quest
+ * Allow closing notes
+ * Some of the settings below can only be enabled in expert mode
+ * Quest settings for most quests, mostly for customized element selection, but also for other things like allowing generic paved surface answer without note
+ * Such customization should be handled with care. There are some safeguards, but modifying element selection could still lead to inappropriate tagging, quests being asked over and over again, and maybe app crashes.
+ * Quests without settings need to be handled individually. Please open an issue if you want specific settings.
+ * UI settings
+ * Quick settings button for switching preset, background and reverse quest order. Also contains a level filter for displayed quests / overlay elements
+ * Quick selector for overlays (on main screen)
+ * long-press custom overlays to edit
+ * Show next quest for this element immediately
+ * Show nearby quests / other quests for same element when quest form is open
+ * Hide button for temporarily hiding quests (long press for permanent hide)
+ * Show keyboard automatically on showing feature search dialog when adding a node
+ * Auto-select first edit when opening edit history
+ * Search features in local language and all languages enabled in the system
+ * Select how many lines the form needs to have to move recent selection to front
+ * Show all main menu items as grid
+ * Add a _switch preset_ button to main menu (only if not a grid)
+ * Capitalize words when entering names
+ * Zoom using volume buttons
+ * Display settings
+ * Disable 3D buildings (currently not available, as 3D buildings are disabled in general with since the MapLibre switch)
+ * Show arrows indicating direction of highlighted way
+ * Highlight geometries for nearby quests
+ * Disable quest solved animation
+ * Provide GPX track and have it always shown on the map
+ * Provide GeoJson file and have geometries shown on the map (and text from _name_ property)
+ * Quest settings
+ * Hide or increase priority of quests depending on time of day
+ * Force resurvey for specific tags
+ * Different quest settings for each preset
+ * Dynamic quest creation for immediately applying changed quest settings and resurvey intervals
+ * Notifications about nearby quests when app is in background
+ * Hide overlay-specific quests when overlay is enabled
+ * Note settings
+ * Create personal notes in a GPX file (adds a new button when creating a note)
+ * Swap OSM and GPX note buttons, for switching default notes
+ * Disable hiding the keyboard before creating a note
+ * Create custom quests like notes
+ * Save full-size photos made for notes
+ * Hide notes created by specific users
+ * Data management settings
+ * Disable auto-download
+ * Disable always downloading map data on manual download, even if data is fresh
+ * Choose tile URL for aerial imagery
+ * Set data retention time
+ * Disable local statistics updates (hides achievement messages)
+ * Import / export
+ * Custom overlays
+ * Quest presets, including per-preset quest settings
+ * Hidden quests
+ * All other settings, including quest settings and recently selected answers. Does not export login data.
+* When uploading from a debug build without being logged in, all uploads return fake success without contacting OSM API. This is used for testing parts of the uploader.
+
+Database and preferences files are compatible with StreetComplete, so if you have root privileges you can transfer them in either direction.
+
+## Contributing quests
+The original [contributing guidelines](README_StreetComplete.md#contributing) are still valid, but note that the [guidelines for contributing a quest](QUEST_GUIDELINES.md) have been significantly relaxed:
+* Creating, moving and deleting nodes is possible
+ * Inserting nodes into a way is possible
+* Guidelines are useful suggestions, but not enforced
+* Quests may be based on external sources like Osmose, not just on element selection
+
+## Changeset differences compared to StreetComplete
+This section is aimed for people trying to decide whether a bad edit done in SCEE is fault of the user or of the app (SCEE modifications).
+In general, SCEE changesets will contain changes very similar to StreetComplete changesets, with following differences:
+* `created_by` is set to `StreetComplete_ee `
+* _AddBuildingType_ has additional answers `barn`, `sty`, `stable`, `cowshed`, `digester`, `presbytery`, `riding_hall`, `sports_hall`, `tent`, `elevator`, and `transformer_tower`
+* _AddCrossingType_ may change `crossing_ref`, `crossing:markings`, and `traffic_calming`
+* _AddPathSurface_ and _AddRoadSurface_ have additional surfaces `metal_grid` and `stepping_stones`
+* _AddMaxSpeed_ may tag `maxspeed:conditional`
+* [Discardable tags](https://wiki.openstreetmap.org/wiki/Discardable_tags) are removed automatically
+* Any node may be moved, even if it is part of a way or relation
+* Any node may be deleted, or have all tags removed if it's not free-floating
+* `check_date:*` may be added without resurvey
+* Wheelchair quests may add `wheelchair:description` and `wheelchair:description:`
+* An element at the same position as a note may be edited (this is blocked in normal SC)
+* Most quests may apply to an extended range of elements (user-defined)
+* Starting with SCEE 52.0, some answers create separate changesets with comment `Other edits in context of: `.
+This happens for changes that can occur in StreetComplete, such as moving or deleting a node, changing shop types, removing surface, changing highway to steps and removing sidewalks.
+Furthermore SCEE adds new answers leading to such a changeset comment:
+ * All quest types related to roads / paths may adjust access tags
+ * Quests types asking about about benches, picnic tables, recycling containers, bicycle parkings and sports tracks/pitches may tag `access=private`
+ * All quest types related to buildings may change `building` to `demolished:building`
+* SCEE contains some additional [quests (scroll to bottom)](app/src/main/java/de/westnordost/streetcomplete/quests/QuestsModule.kt) and [overlays](app/src/main/java/de/westnordost/streetcomplete/overlays/OverlaysModule.kt), recognizable in the files by `EE_QUEST_OFFSET`
+ * They usually do not fulfill the requirements for StreetComplete, and need to be enabled by the user first
+* There are further "quest types" (though neither quests nor overlays, they are identified in `StreetComplete:quest_type` changeset tag)
+ * _TagEdit_: may modify any tag
+ * _AddNode_: adds nodes, free floating or part of ways, (may change tags of existing way node instead of inserting a new one under some circumstances)
## Screenshots
-
-
-## Download
-
-[](https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete)[](https://f-droid.org/packages/de.westnordost.streetcomplete/)[](https://github.com/streetcomplete/StreetComplete/releases/latest)
-
-## Quests
-
-There are quite a few different quest types now and more will be added over time.
-You can see a community-managed [list of all quests in the OSM wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/Quests).
-
-## FAQ
-
-You can find a list of [frequently asked questions in the wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ).
-
-## Contributing
-
-This is an active open-source project, so you can get involved in it easily!
-You can do so **without any programming or OpenStreetMap knowledge**! Just choose a task that you like.
-
-Here are a few things you can do:
-* 🐛 [Test and report issues](CONTRIBUTING.md#testing-and-reporting-issues)
-* 📃 [Translate the app into your language](CONTRIBUTING.md#translating-the-app)
-* 🕵️ [Solve notes left by StreetComplete users](CONTRIBUTING.md#solving-notes)
-* 💡 [Suggest new quests](CONTRIBUTING.md#suggesting-new-quests), or, even better, [implement them](CONTRIBUTING.md#developing-new-quests).
-* ➕ [and more…](CONTRIBUTING.md)
-
-Also, if you like StreetComplete, **spread the word**! ❤️
-
-## License
-
-This software is released under the terms of the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.html).
-
-## Sponsors
-
-
-Many users are currently supporting this app through GitHub sponsors, Liberapay and Patreon. If you like the app, you can join them ☺️ to support the continued development and maintenance of the app.
-
-
-
-Since mid 2020, **JawgMaps** provides their vector map tiles service to StreetComplete for free, i.e. the background map displayed in the app.
-
-
-## Past Sponsors
-
-
-Within the frame of **Prototype Fund** round 15 (March 2024 to August 2024), the German Federal Ministry of Education and Research sponsored Tobias Zwick to work on StreetComplete for iOS (see [progress report](https://github.com/streetcomplete/StreetComplete/issues/5421#issuecomment-2332402123))
-Development on this app was also sponsored in round 8 (September 2020 to February 2021) of the Prototype fund, with focus on collecting more data points and on general improvements of this app.
-
-
-
-The **NLnet foundation** sponsored development on this app in three individual grants with funds from the European Commission:
-Twogrants given to Mateusz Konieczny in 2019 and 2021 enabled him to work on StreetComplete for about one year in total, with a focus on clearer UI and improvements on data collection.
-Furthermore, yet another grant enabled Tobias Zwick to work about five months in 2021/2022 on - most notably - the overlays functionality and measuring with AR.
-
-
-
-In August 2020, the **OpenStreetMap foundation** funded the development of Map Maintenance with StreetComplete within the frame of the microgrants program.
+
diff --git a/README_StreetComplete.md b/README_StreetComplete.md
new file mode 100644
index 00000000000..3f0e52b8d07
--- /dev/null
+++ b/README_StreetComplete.md
@@ -0,0 +1,73 @@
+Readme of [StreetComplete](https://github.com/streetcomplete/StreetComplete)
+
+
+
+StreetComplete is an easy to use editor of OpenStreetMap data available for Android. It can be used without any OpenStreetMap-specific knowledge. It asks simple questions, with answers directly used to edit and improve OpenStreetMap data. The app is aimed at users who do not know anything about OSM tagging schemes but still want to contribute to OpenStreetMap.
+
+StreetComplete automatically looks for nearby places where a survey is needed and shows them as quest markers on its map. Each of these quests can then be solved on site by answering a simple question. For example, tapping on a marker may show the question "What is the name of this road?", with a text field to answer it.
+More examples are shown in the screenshots below.
+
+The user's answer is automatically processed and uploaded directly into the OSM database. Edits are done in meaningful changesets using the user's OSM account.
+Since the app is meant to be used on a survey, it can be used offline and is
+economic with data usage.
+
+To make the app easy to use, quests are limited to those answerable by asking simple questions.
+
+* See the [latest release notes](https://github.com/streetcomplete/StreetComplete/releases).
+
+## Download
+
+[](https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete)[](https://f-droid.org/packages/de.westnordost.streetcomplete/)[](https://github.com/streetcomplete/StreetComplete/releases/latest)
+
+## Quests
+
+There are quite a few different quest types now and more will be added over time.
+You can see a community-managed [list of all quests in the OSM wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/Quests).
+
+## FAQ
+
+You can find a list of [frequently asked questions in the wiki](https://wiki.openstreetmap.org/wiki/StreetComplete/FAQ).
+
+## Contributing
+
+This is an active open-source project, so you can get involved in it easily!
+You can do so **without any programming or OpenStreetMap knowledge**! Just choose a task that you like.
+
+Here are a few things you can do:
+* 🐛 [Test and report issues](CONTRIBUTING.md#testing-and-reporting-issues)
+* 📃 [Translate the app into your language](CONTRIBUTING.md#translating-the-app)
+* 🕵️ [Solve notes left by StreetComplete users](CONTRIBUTING.md#solving-notes)
+* 💡 [Suggest new quests](CONTRIBUTING.md#suggesting-new-quests), or, even better, [implement them](CONTRIBUTING.md#developing-new-quests).
+* ➕ [and more…](CONTRIBUTING.md)
+
+Also, if you like StreetComplete, **spread the word**! ❤️
+
+## License
+
+This software is released under the terms of the [GNU General Public License](http://www.gnu.org/licenses/gpl-3.0.html).
+
+## Sponsors
+
+
+Many users are currently supporting this app through GitHub sponsors, Liberapay and Patreon. If you like the app, you can join them ☺️ to support the continued development and maintenance of the app.
+
+
+
+Since mid 2020, **JawgMaps** provides their vector map tiles service to StreetComplete for free, i.e. the background map displayed in the app.
+
+
+## Past Sponsors
+
+
+Within the frame of **Prototype Fund** round 15 (March 2024 to August 2024), the German Federal Ministry of Education and Research sponsored Tobias Zwick to work on StreetComplete for iOS (see [progress report](https://github.com/streetcomplete/StreetComplete/issues/5421#issuecomment-2332402123))
+Development on this app was also sponsored in round 8 (September 2020 to February 2021) of the Prototype fund, with focus on collecting more data points and on general improvements of this app.
+
+
+
+The **NLnet foundation** sponsored development on this app in three individual grants with funds from the European Commission:
+Twogrants given to Mateusz Konieczny in 2019 and 2021 enabled him to work on StreetComplete for about one year in total, with a focus on clearer UI and improvements on data collection.
+Furthermore, yet another grant enabled Tobias Zwick to work about five months in 2021/2022 on - most notably - the overlays functionality and measuring with AR.
+
+
+
+In August 2020, the **OpenStreetMap foundation** funded the development of Map Maintenance with StreetComplete within the frame of the microgrants program.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f089a6db61d..20e4b774373 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -32,7 +32,7 @@ android {
}
defaultConfig {
- applicationId = "de.westnordost.streetcomplete"
+ applicationId = "de.westnordost.streetcomplete.expert"
minSdk = 25
targetSdk = 35
versionCode = 6100
@@ -42,13 +42,13 @@ android {
buildTypes {
all {
- isMinifyEnabled = true
isShrinkResources = false
// don't use proguard-android-optimize.txt, it is too aggressive, it is more trouble than it is worth
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
testProguardFile("test-proguard-rules.pro")
}
getByName("release") {
+ isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
buildConfigField("boolean", "IS_GOOGLE_PLAY", "false")
}
@@ -83,6 +83,13 @@ android {
)
abortOnError = false
}
+
+ dependenciesInfo {
+ // Disables dependency metadata when building APKs.
+ includeInApk = false
+ // Disables dependency metadata when building Android App Bundles.
+ includeInBundle = false
+ }
namespace = "de.westnordost.streetcomplete"
}
@@ -212,6 +219,23 @@ dependencies {
// image view that allows zoom and pan
implementation("com.github.chrisbanes:PhotoView:2.3.0")
+
+ // faster sqlite library (additional capabilities like R*-tree or json1 not used)
+ // writing 25% faster, reading 5% faster than Android 9 built-in sqlite (tested with 3.36.0)
+ implementation("com.github.requery:sqlite-android:3.45.0")
+ implementation("androidx.sqlite:sqlite:2.4.0")
+
+ // fast json (de)serialization used for database read and write
+ implementation("com.squareup.moshi:moshi:1.15.1")
+
+ // sunset-sunrise parser for lit quests
+ implementation("com.luckycatlabs:SunriseSunsetCalculator:1.2")
+
+ // diff utils for comparing filters modified by quest settings with original
+ implementation("io.github.java-diff-utils:java-diff-utils:4.12")
+
+ // parser for user-supplied GPX tracks
+ implementation("com.github.ticofab:android-gpx-parser:2.3.1")
}
/** Localizations that should be pulled from POEditor */
@@ -319,3 +343,10 @@ tasks.register("copyDefaultStringsToEnStrings") {
.copyTo(File("$projectDir/src/main/res/values-en/strings.xml"), true)
}
}
+
+// this task is EE only, suggestions are used in the tag editor
+tasks.register("generateTagSuggestions") {
+ group = "streetcomplete"
+ version = presetsVersion
+ targetDir = "$projectDir/src/main/assets/tag_editor"
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 4d603486113..32d6d1a1337 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -17,6 +17,15 @@
public static final ** CREATOR;
}
+# crashes when selecting some quests from tag editor
+-keep class androidx.core.app.CoreComponentFactory { *; }
+
+# crashes on start after upgrading to gradle 8 (release version only for some reason, though same rules are used)
+-keepclassmembers public class io.requery.android.database.sqlite.SQLiteConnection { *; }
+
+# after upgrading to gradle 8, stack traces contain "unknown source", which is horribly bad making them rather useless
+-keepattributes SourceFile,LineNumberTable
+
# kotlinx-serialization start ----------------------------------------------------------------------
-keepattributes *Annotation*, InnerClasses
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt
index 545fecd0ce8..dd8fff039a5 100644
--- a/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt
@@ -1,6 +1,6 @@
package de.westnordost.streetcomplete.data
-import android.database.sqlite.SQLiteOpenHelper
+import io.requery.android.database.sqlite.SQLiteOpenHelper
import androidx.test.platform.app.InstrumentationRegistry
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt
new file mode 100644
index 00000000000..fb8828c0e53
--- /dev/null
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/building_colour/BuildingColourItemTest.kt
@@ -0,0 +1,17 @@
+package de.westnordost.streetcomplete.quests.building_colour
+
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+class BuildingColourItemTest {
+ @Test
+ fun parsableAsColorIcon() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ BuildingColour.values().map {
+ assertNotNull(
+ it.asItem(context)
+ )
+ }
+ }
+}
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt
new file mode 100644
index 00000000000..6778f7b7560
--- /dev/null
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/quests/roof_colour/RoofColourItemTest.kt
@@ -0,0 +1,30 @@
+package de.westnordost.streetcomplete.quests.roof_colour
+
+import androidx.test.platform.app.InstrumentationRegistry
+import de.westnordost.streetcomplete.quests.roof_shape.RoofShape
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+class RoofColourItemTest {
+ @Test
+ fun parsableAsColorIcon() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ RoofShape.values().map { shape ->
+ RoofColour.values().map {
+ assertNotNull(
+ it.asItem(context, shape)
+ )
+ }
+ }
+ }
+
+ @Test
+ fun parsableAsColorIconNoShape() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ RoofColour.values().map {
+ assertNotNull(
+ it.asItem(context, null)
+ )
+ }
+ }
+}
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png
index 26f78301e18..98859e15abe 100644
Binary files a/app/src/debug/ic_launcher-playstore.png and b/app/src/debug/ic_launcher-playstore.png differ
diff --git a/app/src/debug/res/drawable-v24/ic_launcher_background.xml b/app/src/debug/res/drawable-v24/ic_launcher_background.xml
new file mode 100644
index 00000000000..e0bf512d334
--- /dev/null
+++ b/app/src/debug/res/drawable-v24/ic_launcher_background.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
index 67048fd38ac..db4457e1045 100644
--- a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,6 @@
-
+
-
+
diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 7353dbd1fd8..bbd3e021239 100644
--- a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png
index 484770a109b..839835651ff 100644
Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
index 5dbe632a90d..1b710a60767 100644
Binary files a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-ldpi/ic_launcher.png b/app/src/debug/res/mipmap-ldpi/ic_launcher.png
deleted file mode 100644
index d09c982db77..00000000000
Binary files a/app/src/debug/res/mipmap-ldpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png
index 4d0ef1cf6cd..bf4acc4f082 100644
Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
index 1f4a37a18bc..3ac2cec7443 100644
Binary files a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
index 84d7aeba7d2..5effc5e126f 100644
Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
index 53ba889b807..035f3378fee 100644
Binary files a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
index dc5f53415ad..2bbd70dd3b1 100644
Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
index 6c8b32832f1..8687756a387 100644
Binary files a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
index 0c73fc9cfda..d6d7834155a 100644
Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
index 72d448d0c88..0c0ec414a5d 100644
Binary files a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/debug/res/values/conf.xml b/app/src/debug/res/values/conf.xml
index e0acbd8a56a..b072d7f3a34 100644
--- a/app/src/debug/res/values/conf.xml
+++ b/app/src/debug/res/values/conf.xml
@@ -1,4 +1,4 @@
- de.westnordost.streetcomplete.debug.fileprovider
+ de.westnordost.streetcomplete.expert.debug.fileprovider
diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml
deleted file mode 100644
index 43d595e1c52..00000000000
--- a/app/src/debug/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #FFD42A
-
\ No newline at end of file
diff --git a/app/src/debug/res/values/untranslatableStrings.xml b/app/src/debug/res/values/untranslatableStrings.xml
index 22a212ab2dd..9f6511dc943 100644
--- a/app/src/debug/res/values/untranslatableStrings.xml
+++ b/app/src/debug/res/values/untranslatableStrings.xml
@@ -1,4 +1,4 @@
- StreetComplete Dev
+ SCEE Dev
diff --git a/app/src/debug/res/xml/file_paths.xml b/app/src/debug/res/xml/file_paths.xml
index b9f955c5421..d3478812886 100644
--- a/app/src/debug/res/xml/file_paths.xml
+++ b/app/src/debug/res/xml/file_paths.xml
@@ -1,4 +1,4 @@
-
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b347cb32719..b3a5f249a0b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,6 +17,11 @@
Foreground service types exists since Android 10.
-->
+
+
+
+
+
@@ -36,6 +41,7 @@
+
+
+
+
+
+ { androidContext().assets }
factory { androidContext().resources }
- single { CrashReportExceptionHandler(androidContext(), get(), "crashreport.txt") }
+ single { CrashReportExceptionHandler(androidContext(), get(), get(), "crashreport.txt") }
single { DatabaseLogger(get()) }
single { SoundFx(androidContext()) }
single { HttpClient {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt
new file mode 100644
index 00000000000..010de63814e
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/Prefs.kt
@@ -0,0 +1,80 @@
+package de.westnordost.streetcomplete
+
+/** Constant class to have all the identifiers for SCEE shared preferences in one place */
+object Prefs {
+ const val VOLUME_ZOOM = "volume_button_zoom"
+ const val SHOW_3D_BUILDINGS = "3d_buildings"
+ const val QUEST_GEOMETRIES = "quest_geometries"
+ const val AUTO_DOWNLOAD = "auto_download"
+ const val GPS_INTERVAL = "gps_interval"
+ const val NETWORK_INTERVAL = "network_interval"
+ const val HIDE_NOTES_BY_USERS = "hide_notes_by_users2"
+ const val MANUAL_DOWNLOAD_OVERRIDE_CACHE = "manual_download_override_cache"
+ const val QUICK_SETTINGS = "quick_settings"
+ const val ALLOWED_LEVEL = "allowed_level"
+ const val ALLOWED_LEVEL_TAGS = "allowed_level_tags"
+ const val RESURVEY_KEYS = "resurvey_keys"
+ const val RESURVEY_DATE = "resurvey_date"
+ const val GPX_BUTTON = "gpx_button"
+ const val SWAP_GPX_NOTE_BUTTONS = "swap_gpx_note_buttons"
+ const val HIDE_KEYBOARD_FOR_NOTE = "hide_keyboard_for_note"
+ const val OFFSET_FIX = "offset_fix"
+ const val DAY_NIGHT_BEHAVIOR = "day_night_behavior"
+ const val QUEST_SETTINGS_PER_PRESET = "quest_settings_per_preset"
+ const val SHOW_HIDE_BUTTON = "show_hide_button"
+ const val SELECT_FIRST_EDIT = "select_first_edit"
+ const val BAN_CHECK_ERROR_COUNT = "ban_check_error_count"
+ const val DATA_RETAIN_TIME = "data_retain_time"
+ const val FAVS_FIRST_MIN_LINES = "favs_first_min_lines"
+ const val SHOW_NEARBY_QUESTS = "show_nearby_quests"
+ const val SHOW_NEARBY_QUESTS_DISTANCE = "show_nearby_quests_distance"
+ const val CUSTOM_OVERLAY_IDX_FILTER = "custom_overlay_idx_filter"
+ const val CUSTOM_OVERLAY_IDX_COLOR_KEY = "custom_overlay_idx_color_key"
+ const val CUSTOM_OVERLAY_IDX_NAME = "custom_overlay_idx_name"
+ const val CUSTOM_OVERLAY_IDX_ICON = "custom_overlay_idx_icon"
+ const val CUSTOM_OVERLAY_IDX_DASH_FILTER = "custom_overlay_idx_dash_filter"
+ const val CUSTOM_OVERLAY_IDX_HIGHLIGHT_MISSING_DATA = "custom_overlay_idx_highlight_missing_data"
+ const val CUSTOM_OVERLAY_INDICES = "custom_overlay_indices"
+ const val CUSTOM_OVERLAY_SELECTED_INDEX = "custom_overlay_selected_index"
+ const val SHOW_SOLVED_ANIMATION = "show_solved_animation"
+ const val SHOW_NEXT_QUEST_IMMEDIATELY = "show_next_quest_immediately"
+ const val MAIN_MENU_FULL_GRID = "main_menu_full_grid"
+ const val CREATE_POI_RECENT_FEATURE_IDS = "create_poi_recent_feature_ids"
+ const val DYNAMIC_QUEST_CREATION = "dynamic_quest_creation"
+ const val QUEST_MONITOR = "quest_monitor"
+ const val QUEST_MONITOR_GPS = "quest_monitor_gps"
+ const val QUEST_MONITOR_NET = "quest_monitor_net"
+ const val QUEST_MONITOR_RADIUS = "quest_monitor_radius"
+ const val QUEST_MONITOR_DOWNLOAD = "quest_monitor_download"
+ const val SHOW_GPX_TRACK = "show_gpx_track"
+ const val RASTER_TILE_URL = "raster_tile_url"
+ const val RASTER_TILE_MAXZOOM = "raster_tile_maxzoom"
+ const val CREATE_EXTERNAL_QUESTS = "create_external_quests"
+ const val SAVE_PHOTOS = "save_photos"
+ const val EXPERT_MODE = "expert_mode"
+ const val SHOW_WAY_DIRECTION = "show_way_direction"
+ const val SEARCH_MORE_LANGUAGES = "search_more_languages"
+ const val NO_SATELLITE_LABEL = "no_satellite_label"
+ const val CAPS_WORD_NAME_INPUT = "caps_word_name_input"
+ const val INSERT_NODE_RECENT_FEATURE_IDS = "insert_node_recent_feature_ids"
+ const val OVERLAY_QUICK_SELECTOR = "overlay_quick_selector"
+ const val CREATE_NODE_LAST_TAGS_FOR_FEATURE = "create_node_last_tags_for_"
+ const val CREATE_NODE_SHOW_KEYBOARD = "create_node_show_keyboard"
+ const val UPDATE_LOCAL_STATISTICS = "update_local_statistics"
+ const val HIDE_OVERLAY_QUESTS = "hide_overlay_quests"
+ const val MAIN_MENU_SWITCH_PRESETS = "main_menu_switch_presets"
+ const val DISABLE_NAVIGATION_MODE = "disable_navigation_mode"
+ const val TEMP_LOGGER = "temp_logger"
+ const val SHOW_CUSTOM_GEOMETRY = "show_custom_geometry"
+ const val THEME_BACKGROUND = "theme.background_type"
+ const val REALLY_ALL_NOTES = "really_all_notes"
+ const val ROTATE_WHILE_ZOOMING = "rotate_while_zooming"
+ const val ROTATE_ANGLE_THRESHOLD = "rotate_angle_threshold"
+ const val OVERRIDE_COUNTRY_RESTRICTIONS = "override_country_restrictions"
+
+ enum class DayNightBehavior(val titleResId: Int) {
+ IGNORE(R.string.day_night_ignore),
+ PRIORITY(R.string.day_night_priority),
+ VISIBILITY(R.string.day_night_visibility)
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt
index 2ab494a72a2..5deced9553d 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/StreetCompleteApplication.kt
@@ -1,7 +1,11 @@
package de.westnordost.streetcomplete
+import android.app.ActivityManager
+import android.app.ActivityManager.MemoryInfo
import android.app.Application
import android.content.ComponentCallbacks2
+import android.content.Context
+import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
@@ -12,6 +16,7 @@ import androidx.work.WorkManager
import com.russhwolf.settings.SettingsListener
import de.westnordost.streetcomplete.data.CacheTrimmer
import de.westnordost.streetcomplete.data.CleanerWorker
+import de.westnordost.streetcomplete.data.DatabaseInitializer
import de.westnordost.streetcomplete.data.Preloader
import de.westnordost.streetcomplete.data.allEditTypesModule
import de.westnordost.streetcomplete.data.changelog.changelogModule
@@ -33,6 +38,7 @@ import de.westnordost.streetcomplete.data.osmApiModule
import de.westnordost.streetcomplete.data.osmnotes.edits.noteEditsModule
import de.westnordost.streetcomplete.data.osmnotes.notequests.osmNoteQuestModule
import de.westnordost.streetcomplete.data.osmnotes.notesModule
+import de.westnordost.streetcomplete.data.externalsource.externalSourceModule
import de.westnordost.streetcomplete.data.overlays.overlayModule
import de.westnordost.streetcomplete.data.platform.platformModule
import de.westnordost.streetcomplete.data.preferences.Preferences
@@ -54,9 +60,13 @@ import de.westnordost.streetcomplete.quests.questsModule
import de.westnordost.streetcomplete.screens.about.aboutScreenModule
import de.westnordost.streetcomplete.screens.main.mainModule
import de.westnordost.streetcomplete.screens.measure.arModule
+import de.westnordost.streetcomplete.screens.settings.LAST_KNOWN_DB_VERSION
+import de.westnordost.streetcomplete.screens.settings.renamedQuests
+import de.westnordost.streetcomplete.screens.settings.renameUpdatedQuests
import de.westnordost.streetcomplete.screens.settings.settingsModule
import de.westnordost.streetcomplete.screens.user.userScreenModule
import de.westnordost.streetcomplete.util.CrashReportExceptionHandler
+import de.westnordost.streetcomplete.util.TempLogger
import de.westnordost.streetcomplete.util.getSelectedLocales
import de.westnordost.streetcomplete.util.ktx.deleteRecursively
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
@@ -98,6 +108,10 @@ class StreetCompleteApplication : Application() {
override fun onCreate() {
super.onCreate()
+ // got a crash report where prefs were not initialized, not sure how this can happen for a
+ // single person and not for everyone, but this should help (means that we keep using android-specific prefs interface)
+ preferences = getSharedPreferences(packageName + "_preferences", Context.MODE_PRIVATE)
+
deleteDatabase(ApplicationConstants.OLD_DATABASE_NAME)
startKoin {
@@ -141,12 +155,18 @@ class StreetCompleteApplication : Application() {
overlayModule,
urlConfigModule,
urlConfigModule,
- platformModule
+ platformModule,
+ externalSourceModule,
)
}
setLoggerInstances()
+ applicationScope.launch {
+ editHistoryController.deleteSyncedOlderThan(nowAsEpochMilliseconds() - ApplicationConstants.MAX_UNDO_HISTORY_AGE)
+ preloader.preload()
+ }
+
// Force logout users who are logged in with OAuth 1.0a, they need to re-authenticate with OAuth 2
if (prefs.hasOAuth1AccessToken) {
userLoginController.logOut()
@@ -156,11 +176,6 @@ class StreetCompleteApplication : Application() {
crashReportExceptionHandler.install()
- applicationScope.launch {
- preloader.preload()
- editHistoryController.deleteSyncedOlderThan(nowAsEpochMilliseconds() - ApplicationConstants.MAX_UNDO_HISTORY_AGE)
- }
-
if (isConnected) userUpdater.update()
enqueuePeriodicCleanupWork()
@@ -169,19 +184,39 @@ class StreetCompleteApplication : Application() {
resurveyIntervalsUpdater.update()
+ require(DatabaseInitializer.DB_VERSION == LAST_KNOWN_DB_VERSION.toInt()) { "update database import/export" }
val lastVersion = prefs.lastDataVersion
+
if (BuildConfig.VERSION_NAME != lastVersion) {
prefs.lastDataVersion = BuildConfig.VERSION_NAME
if (lastVersion != null) {
onNewVersion()
}
+ // update prefs referring to renamed quests
+ val prefsToRename = preferences.all.filter { pref ->
+ val v = pref.value
+ renamedQuests.keys.any { pref.key.contains(it) || (v is String && v.contains(it)) }
+ }
+ val e = preferences.edit()
+ prefsToRename.forEach {
+ e.remove(it.key)
+ when (it.value) {
+ is String -> e.putString(it.key.renameUpdatedQuests(), (it.value as String).renameUpdatedQuests())
+ is Boolean -> e.putBoolean(it.key.renameUpdatedQuests(), it.value as Boolean)
+ is Int -> e.putInt(it.key.renameUpdatedQuests(), it.value as Int)
+ is Long -> e.putLong(it.key.renameUpdatedQuests(), it.value as Long)
+ is Float -> e.putFloat(it.key.renameUpdatedQuests(), it.value as Float)
+ is Set<*> -> e.putStringSet(it.key.renameUpdatedQuests(), it.value as? Set?)
+ }
+ }
+ e.apply()
}
clearTangramCache()
-
settingsListeners += prefs.onLanguageChanged { updateDefaultLocales() }
settingsListeners += prefs.onThemeChanged { updateTheme(it) }
}
+
private fun onNewVersion() {
// on each new version, invalidate quest cache
downloadedTilesController.invalidateAll()
@@ -198,9 +233,11 @@ class StreetCompleteApplication : Application() {
ComponentCallbacks2.TRIM_MEMORY_COMPLETE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// very low on memory -> drop caches
cacheTrimmer.clearCaches()
+ Log.i("StreetCompleteApplication", "onTrimMemory, level $level: ${getMemString()}")
}
ComponentCallbacks2.TRIM_MEMORY_MODERATE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
// memory needed, but not critical -> trim only
+ Log.i("StreetCompleteApplication", "onTrimMemory, level $level: ${getMemString()}")
cacheTrimmer.trimCaches()
}
}
@@ -210,13 +247,25 @@ class StreetCompleteApplication : Application() {
LocaleList.setDefault(getSelectedLocales(prefs))
}
+ private fun getMemString(): String {
+ val memInfo = MemoryInfo()
+ getSystemService()?.getMemoryInfo(memInfo)
+ return "${memInfo.availMem / 0x100000L} MB of ${memInfo.totalMem / 0x100000L} available, mem low: ${memInfo.lowMemory}, mem low threshold: ${memInfo.threshold / 0x100000L} MB"
+ }
+
private fun updateTheme(theme: Theme) {
+ if (theme == Theme.DARK_CONTRAST || theme == Theme.DARK)
+ // night mode off to trigger reload (maybe there is a way to do it without this, but at least ir works...)
+ AppCompatDelegate.setDefaultNightMode(Theme.LIGHT.appCompatNightMode)
AppCompatDelegate.setDefaultNightMode(theme.appCompatNightMode)
}
private fun setLoggerInstances() {
Log.instances.add(AndroidLogger())
- Log.instances.add(databaseLogger)
+ if (prefs.getBoolean(Prefs.TEMP_LOGGER, false))
+ Log.instances.add(TempLogger)
+ else
+ Log.instances.add(databaseLogger)
}
private fun enqueuePeriodicCleanupWork() {
@@ -231,6 +280,10 @@ class StreetCompleteApplication : Application() {
)
}
+ companion object {
+ lateinit var preferences: SharedPreferences
+ }
+
private val isConnected: Boolean
get() = getSystemService()?.activeNetworkInfo?.isConnected == true
@@ -248,6 +301,6 @@ class StreetCompleteApplication : Application() {
private val Theme.appCompatNightMode: Int get() = when (this) {
Theme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
- Theme.DARK -> AppCompatDelegate.MODE_NIGHT_YES
+ Theme.DARK, Theme.DARK_CONTRAST -> AppCompatDelegate.MODE_NIGHT_YES
Theme.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt
index c6fb89f06a4..e7c2ca78584 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/AllEditTypes.kt
@@ -3,10 +3,12 @@ package de.westnordost.streetcomplete.data
import de.westnordost.streetcomplete.data.osm.edits.EditType
class AllEditTypes(
- registries: List>
+ registries: List>,
+ extras: List = emptyList()
) : AbstractCollection() {
private val byName = registries.flatten().associateByTo(LinkedHashMap()) { it.name }
+ .apply { extras.forEach { put(it.name, it) } }
override val size: Int get() = byName.size
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt b/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt
index 91c2764e11a..58e4d2dd47f 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/AndroidDatabase.kt
@@ -3,21 +3,20 @@ package de.westnordost.streetcomplete.data
import android.annotation.SuppressLint
import android.content.ContentValues
import android.database.Cursor
-import android.database.sqlite.SQLiteDatabase
-import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT
-import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL
-import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE
-import android.database.sqlite.SQLiteDatabase.CONFLICT_NONE
-import android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE
-import android.database.sqlite.SQLiteDatabase.CONFLICT_ROLLBACK
-import android.database.sqlite.SQLiteStatement
+import io.requery.android.database.sqlite.SQLiteDatabase
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_NONE
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE
+import io.requery.android.database.sqlite.SQLiteDatabase.CONFLICT_ROLLBACK
+import io.requery.android.database.sqlite.SQLiteStatement
import androidx.core.database.getBlobOrNull
import androidx.core.database.getDoubleOrNull
import androidx.core.database.getFloatOrNull
import androidx.core.database.getIntOrNull
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
-import androidx.core.database.sqlite.transaction
import de.westnordost.streetcomplete.data.ConflictAlgorithm.ABORT
import de.westnordost.streetcomplete.data.ConflictAlgorithm.FAIL
import de.westnordost.streetcomplete.data.ConflictAlgorithm.IGNORE
@@ -131,7 +130,16 @@ class AndroidDatabase(private val db: SQLiteDatabase) : Database {
return db.delete(table, where, strArgs)
}
- override fun transaction(block: () -> T): T = db.transaction { block() }
+ override fun transaction(block: () -> T): T {
+ db.beginTransaction()
+ try {
+ val result = block()
+ db.setTransactionSuccessful()
+ return result
+ } finally {
+ db.endTransaction()
+ }
+ }
}
private fun Array.primitivesArrayToStringArray() = Array(size) { i ->
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt b/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt
index 81b5981d335..24498c1341f 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/Cleaner.kt
@@ -1,6 +1,8 @@
package de.westnordost.streetcomplete.data
+import com.russhwolf.settings.ObservableSettings
import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.Prefs
import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController
import de.westnordost.streetcomplete.data.logs.LogsController
import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader
@@ -24,13 +26,14 @@ class Cleaner(
private val downloadedTilesController: DownloadedTilesController,
private val logsController: LogsController,
private val mapTilesDownloader: MapTilesDownloader,
+ private val prefs: ObservableSettings,
) {
private val scope = CoroutineScope(SupervisorJob() + CoroutineName("Cleaner") + Dispatchers.IO)
fun cleanOld() = scope.launch {
val time = nowAsEpochMilliseconds()
- val oldDataTimestamp = nowAsEpochMilliseconds() - ApplicationConstants.DELETE_OLD_DATA_AFTER
+ val oldDataTimestamp = nowAsEpochMilliseconds() - prefs.getInt(Prefs.DATA_RETAIN_TIME, ApplicationConstants.DELETE_OLD_DATA_AFTER_DAYS) * 24L * 60 * 60 * 1000
noteController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS)
mapDataController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS)
downloadedTilesController.deleteOlderThan(oldDataTimestamp)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
index 184718c1514..2736f51de56 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
@@ -190,6 +190,7 @@ object DatabaseInitializer {
}
if (oldVersion <= 8 && newVersion > 8) {
db.renameQuest("AddPicnicTableCover", "AddAmenityCover")
+ db.renameValue(ElementEditsTable.NAME, ElementEditsTable.Columns.QUEST_TYPE,"ExternalQuest", "CustomQuest")
}
if (oldVersion <= 9 && newVersion > 9) {
db.exec("DROP TABLE ${DownloadedTilesTable.NAME};")
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt
new file mode 100644
index 00000000000..ed912406e60
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt
@@ -0,0 +1,261 @@
+package de.westnordost.streetcomplete.data
+
+import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.data.download.tiles.TilePos
+import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.osm.mapdata.toPolygon
+import de.westnordost.streetcomplete.util.logs.Log
+import de.westnordost.streetcomplete.util.math.area
+import de.westnordost.streetcomplete.util.math.distanceTo
+import de.westnordost.streetcomplete.util.math.enclosingBoundingBox
+import de.westnordost.streetcomplete.util.math.initialBearingTo
+import de.westnordost.streetcomplete.util.math.translate
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlin.math.sqrt
+
+private const val TAG = "GpxImport"
+
+// file slightly modified from https://github.com/streetcomplete/StreetComplete/pull/5369
+data class GpxImportData(
+ val displayTrack: Boolean,
+ val downloadAlongTrack: Boolean,
+ val trackpoints: List,
+ val downloadBBoxes: List,
+ val areaToDownloadInSqkm: Double,
+)
+
+private class DecoratedBoundingBox(val polygon: Iterable) {
+ val boundingBox = polygon.enclosingBoundingBox()
+ val area = boundingBox.area()
+ val tiles = boundingBox.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM)
+ .asTilePosSequence()
+ val numberOfTiles = tiles.count()
+}
+
+// TODO sgr: refactor function signature when adapting UI
+/**
+ * @param originalTrackPoints points from GPX
+ * @param displayTrack display the track on the map after import
+ * @param minDownloadDistance in meters; points within minDownloadDistance along the track should be downloaded
+ */
+suspend fun importGpx(
+ originalTrackPoints: List,
+ displayTrack: Boolean,
+ minDownloadDistance: Double,
+): Result = withContext(Dispatchers.Default) {
+ require(minDownloadDistance in 10.0..500.0) {
+ "minDownloadDistance needs to be of reasonable size"
+ }
+
+ /* Algorithm overview:
+ *
+ * Given that two resampled points A and B are at most 2 * minDownloadDistance away from each
+ * other and any track point between them is at most minDownloadDistance away from either A or B,
+ * an area that fully contains the track between A and B is given by a square S_track centered
+ * on the middle point between A and B, with side length 2 * minDownloadDistance and rotated
+ * such that two of its sides align with the vector from A to B. As we need to cover the area
+ * within minDownloadDistance of any track point (which might lie almost on the edge of S_track),
+ * a square S_min centered and rotated the same as S_track, but with
+ * side length = 4 * minDownloadDistance is a handy upper bound.
+ *
+ * If we download two non-rotated squares centered on A and B, they are guaranteed to contain
+ * S_min if their side length is at least 4 * minDownloadDistance / sqrt(2) - the worst case
+ * being were S_min is rotated 45 degrees with respect to the non-rotated squares.
+ */
+ val maxSampleDistance = 2 * minDownloadDistance
+ val coveringSquareHalfLength = 2 * minDownloadDistance / sqrt(2.0)
+
+ var progress = 0
+ val mergedBBoxes = originalTrackPoints
+ .asSequence()
+ // TODO sgr: just a test how one could decorate sequences with a callback
+ // -> this approach would need orchestration at this level from UI code
+ .map {
+ progress++
+ if (progress % 500 == 0) {
+ Log.d(TAG, "updating progress: ${progress / originalTrackPoints.size}")
+ }
+ it
+ }
+ .addInterpolatedPoints(maxSampleDistance)
+ .discardRedundantPoints(maxSampleDistance)
+ .mapToCenteredSquares(coveringSquareHalfLength)
+ .determineBBoxesToDownload()
+ .mergeBBoxesToDownload()
+
+ return@withContext Result.success(
+ GpxImportData(
+ displayTrack,
+ true,
+ originalTrackPoints,
+ mergedBBoxes.map { it.boundingBox }.toList(),
+ mergedBBoxes
+ .flatMap { it.tiles }
+ .distinct()
+ .sumOf { it.asBoundingBox(ApplicationConstants.DOWNLOAD_TILE_ZOOM).area() }
+ / 1000000
+ )
+ )
+}
+
+/**
+ * TODO sgr: convert implementation to real sequence.. might be tricky
+ * Iteratively merge bounding boxes to save download calls in trade for a few more unique tiles
+ * downloaded
+ */
+private fun Sequence.mergeBBoxesToDownload(): Sequence {
+ var bBoxes = this.toList()
+ val mergedBBoxes = ArrayList()
+ while (mergedBBoxes.size < bBoxes.size) {
+ Log.d(TAG, "start a new round of bounding box merging")
+ var currentBBox: DecoratedBoundingBox? = null
+ for (bBox in bBoxes) {
+ if (currentBBox == null) {
+ currentBBox = bBox
+ continue
+ }
+ val mergedBBox = DecoratedBoundingBox(bBox.polygon + currentBBox.polygon)
+ // merge two adjacent boxes if at most one additional tile needs to be downloaded to save one call
+ currentBBox =
+ if (mergedBBox.numberOfTiles <= (currentBBox.tiles + bBox.tiles).toHashSet().size + 1) {
+ Log.d(TAG, "merge currentBBox with previous one")
+ mergedBBox
+ } else {
+ Log.d(TAG, "keep currentBBox separate from previous one")
+ mergedBBoxes.add(currentBBox)
+ bBox
+ }
+ }
+ currentBBox?.let { mergedBBoxes.add(it) }
+ if (mergedBBoxes.size < bBoxes.size) {
+ Log.d(TAG, "reduced bounding boxes from ${bBoxes.size} to ${mergedBBoxes.size}")
+ bBoxes = mergedBBoxes.toList()
+ mergedBBoxes.clear()
+ } else {
+ Log.d(TAG, "final number of bounding boxes: ${mergedBBoxes.size}")
+ }
+ }
+ return mergedBBoxes.asSequence()
+}
+
+private fun Sequence.determineBBoxesToDownload(): Sequence {
+ var currentBBox: DecoratedBoundingBox? = null
+ val uniqueTilesToDownload = HashSet()
+ val inputIterator = this.map { DecoratedBoundingBox(it.toPolygon()) }.withIndex().iterator()
+ return sequence {
+ for ((index, newBBox) in inputIterator) {
+ if (currentBBox == null) {
+ currentBBox = newBBox
+ yield(newBBox)
+ continue
+ }
+
+ if (!newBBox.tiles.any { tilePos -> tilePos !in uniqueTilesToDownload }) {
+ Log.d(TAG, "omit bounding box #$index, all tiles already scheduled for download")
+ continue
+ }
+
+ val extendedBBox = DecoratedBoundingBox(currentBBox!!.polygon + newBBox.polygon)
+ currentBBox = if (
+ // no additional tile needed to extend the polygon and download newBBox together with currentBBox
+ extendedBBox.numberOfTiles <= (currentBBox!!.tiles + newBBox.tiles).toHashSet().size
+ ||
+ // downloaded area is not increased by extending the current polygon instead of downloading separately
+ extendedBBox.area < currentBBox!!.area + newBBox.area
+ ) {
+ Log.d(TAG, "extend currentBBox with bounding box #$index")
+ extendedBBox
+ } else {
+ Log.d(TAG, "schedule currentBBox, start new with bounding box #$index")
+ yield(currentBBox!!)
+ uniqueTilesToDownload.addAll(currentBBox!!.tiles)
+ newBBox
+ }
+ }
+ currentBBox?.let { yield(it) }
+ }
+}
+
+/**
+ * Transform a sequence of points to a sequence of bounding boxes centered on the points.
+ */
+private fun Sequence.mapToCenteredSquares(halfSideLength: Double): Sequence =
+ map {
+ arrayListOf(
+ it.translate(halfSideLength, 0.0),
+ it.translate(halfSideLength, 90.0),
+ it.translate(halfSideLength, 180.0),
+ it.translate(halfSideLength, 270.0)
+ ).enclosingBoundingBox()
+ }
+
+/**
+ * Ensure points are at most samplingDistance away from each other.
+ *
+ * Given two consecutive points A, B which are more than samplingDistance away from each other,
+ * add intermediate points on the line from A to B, samplingDistance away from each other until the
+ * last one is <= samplingDistance away from B.
+ */
+private fun Sequence.addInterpolatedPoints(samplingDistance: Double): Sequence {
+ var candidatePoint: LatLon? = null
+ val seq = this.flatMap { currentPoint ->
+ if (candidatePoint == null) {
+ candidatePoint = currentPoint
+ return@flatMap emptySequence()
+ }
+ val interpolatedPoints = interpolate(candidatePoint!!, currentPoint, samplingDistance)
+ candidatePoint = currentPoint
+ return@flatMap interpolatedPoints
+ }
+ return seq + sequenceOf(candidatePoint).mapNotNull { it }
+}
+
+/**
+ * Interpolate points between start (included) and end (not included)
+ *
+ * Returned points are samplingDistance away from each other and on the line between start and end.
+ * The last returned point is <= samplingDistance away from end.
+ */
+private fun interpolate(start: LatLon, end: LatLon, samplingDistance: Double): Sequence =
+ sequence {
+ val bearing = start.initialBearingTo(end)
+ var intermediatePoint = start
+ while (true) {
+ yield(intermediatePoint)
+ intermediatePoint = intermediatePoint.translate(samplingDistance, bearing)
+ if (intermediatePoint.distanceTo(end) <= samplingDistance) {
+ break
+ }
+ }
+ }
+
+/**
+ * Discard redundant points, such that no three remaining points A, B, C exist where B is less than
+ * samplingDistance away from both A and C
+ */
+private fun Sequence.discardRedundantPoints(samplingDistance: Double): Sequence {
+ var lastRetainedPoint: LatLon? = null
+ var candidatePoint: LatLon? = null
+ return this.flatMap { currentPoint ->
+ sequence {
+ if (candidatePoint == null) {
+ candidatePoint = currentPoint
+ } else if (lastRetainedPoint == null) {
+ lastRetainedPoint = candidatePoint
+ candidatePoint = currentPoint
+ } else if (lastRetainedPoint!!.distanceTo(candidatePoint!!) < samplingDistance
+ && candidatePoint!!.distanceTo(currentPoint) < samplingDistance
+ ) {
+ // discard candidatePoint
+ candidatePoint = currentPoint
+ } else {
+ lastRetainedPoint = candidatePoint
+ yield(lastRetainedPoint!!)
+ candidatePoint = currentPoint
+ }
+ }
+ } + sequenceOf(candidatePoint).mapNotNull { it }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt b/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt
index 4901dcbd042..e10d06e586e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/ObjectTypeRegistry.kt
@@ -5,32 +5,30 @@ package de.westnordost.streetcomplete.data
* 2. or recalled by ordinal
* 3. or iterated in the order as specified in the constructor
*/
-open class ObjectTypeRegistry(ordinalsAndEntries: List>) : AbstractList() {
+open class ObjectTypeRegistry(private val ordinalsAndEntries: List>) : AbstractList() {
- private val byName: Map
- private val byOrdinal: Map
- private val ordinalByObject: Map
- private val objects = ordinalsAndEntries.map { it.second }
+ protected val byName = hashMapOf()
+ protected val byOrdinal = hashMapOf()
+ protected val ordinalByObject = hashMapOf()
+ protected val objects = mutableListOf()
- init {
- val byNameMap = mutableMapOf()
- val highestOrdinal = ordinalsAndEntries.maxBy { it.first }.first
- val byOrdinalMap = HashMap(highestOrdinal + 1)
+ init { reloadInit() }
+
+ protected fun reloadInit() {
for ((ordinal, objectType) in ordinalsAndEntries) {
- val typeName = objectType::class.simpleName!!
- require(!byNameMap.containsKey(typeName)) {
+ val typeName = objectType::class.simpleName!!.intern()
+ require(!byName.containsKey(typeName)) {
"A object type's name must be unique! \"$typeName\" is defined twice!"
}
- require(!byOrdinalMap.containsKey(ordinal)) {
- val otherTypeName = byOrdinalMap[ordinal]!!::class.simpleName!!
+ require(!byOrdinal.containsKey(ordinal)) {
+ val otherTypeName = byOrdinal[ordinal]!!::class.simpleName!!
"Duplicate ordinal for \"$typeName\" and \"$otherTypeName\""
}
- byNameMap[typeName] = objectType
- byOrdinalMap[ordinal] = objectType
+ byName[typeName] = objectType
+ byOrdinal[ordinal] = objectType
}
- ordinalByObject = ordinalsAndEntries.associate { it.second to it.first }
- byName = byNameMap
- byOrdinal = byOrdinalMap
+ ordinalsAndEntries.associateTo(ordinalByObject) { it.second to it.first }
+ ordinalsAndEntries.mapTo(objects) { it.second }
}
fun getByName(typeName: String): T? = byName[typeName]
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
index 4cb80097bd6..f5e92b86e3a 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/OsmApiModule.kt
@@ -24,7 +24,7 @@ val OSM_API_URL =
val osmApiModule = module {
- factory { Cleaner(get(), get(), get(), get(), get(), get()) }
+ factory { Cleaner(get(), get(), get(), get(), get(), get(), get()) }
factory { CacheTrimmer(get(), get()) }
factory { MapDataApiClient(get(), OSM_API_URL, get(), get(), get()) }
factory { NotesApiClient(get(), OSM_API_URL, get(), get()) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
index e95fc04d48f..c276f0619a2 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt
@@ -1,14 +1,38 @@
package de.westnordost.streetcomplete.data
import android.content.Context
-import android.database.sqlite.SQLiteDatabase
-import android.database.sqlite.SQLiteOpenHelper
+import io.requery.android.database.sqlite.SQLiteDatabase
+import io.requery.android.database.sqlite.SQLiteOpenHelper
+import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestTable
+import de.westnordost.streetcomplete.quests.osmose.OsmoseTable
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables
class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) :
SQLiteOpenHelper(context, dbName, null, DatabaseInitializer.DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
DatabaseInitializer.onCreate(AndroidDatabase(db))
+
+ // external source and osmose tables (get created in init as well, but apparently may still cause issues)
+ db.execSQL(ExternalSourceQuestTables.CREATE_HIDDEN)
+ db.execSQL(ExternalSourceQuestTables.CREATE_EDITS)
+ db.execSQL(OsmoseTable.CREATE_IF_NOT_EXISTS)
+ db.execSQL(OsmoseTable.CREATE_SPATIAL_INDEX_IF_NOT_EXISTS)
+ }
+
+ init {
+ // create some EE tables if not existing
+ // this is to avoid actual db upgrade to keep compatibility with upstream
+
+ // create other source tables
+ writableDatabase.execSQL(ExternalSourceQuestTables.CREATE_HIDDEN)
+ writableDatabase.execSQL(ExternalSourceQuestTables.CREATE_EDITS)
+
+ // create osmose table
+ writableDatabase.execSQL(OsmoseTable.CREATE_IF_NOT_EXISTS)
+ writableDatabase.execSQL(OsmoseTable.CREATE_SPATIAL_INDEX_IF_NOT_EXISTS)
+ // create osm quests element id index if not existing
+ writableDatabase.execSQL(OsmQuestTable.CREATE_ELEMENT_ID_INDEX_IF_NOT_EXISTS)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt
index 37100e91cf6..343c48db1c7 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadController.kt
@@ -15,7 +15,11 @@ class DownloadController(private val context: Context) {
* @param isUserInitiated whether this shall be a priority download (cancels previous downloads
* and puts itself in the front)
*/
- fun download(bbox: BoundingBox, isUserInitiated: Boolean = false) {
+ fun download(bbox: BoundingBox, isUserInitiated: Boolean = false, enqueue: Boolean = false) {
+ if (enqueue && DownloadWorker.downloading) {
+ DownloadWorker.enqueuedDownloads.add(bbox)
+ return
+ }
WorkManager.getInstance(context).enqueueUniqueWork(
Downloader.TAG,
if (isUserInitiated) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt
index 3b260a92a98..2fa77bbdcbc 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadModule.kt
@@ -15,7 +15,7 @@ val downloadModule = module {
factory { MobileDataAutoDownloadStrategy(get(), get()) }
factory { WifiAutoDownloadStrategy(get(), get()) }
- single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync"))) }
+ single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync")), get()) }
single { get() }
single { DownloadController(get()) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt
index 9a817005f01..20fccad4255 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/DownloadWorker.kt
@@ -12,9 +12,9 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.Prefs
import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
import de.westnordost.streetcomplete.data.sync.createSyncNotification
-import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Downloads all quests and tiles in a given area asynchronously.
@@ -43,20 +43,34 @@ class DownloadWorker(
override suspend fun doWork(): Result {
val bbox: BoundingBox = inputData.getString(ARG_BBOX)?.let { Json.decodeFromString(it) }
?: return Result.failure()
+ downloading = true
return try {
val isPriorityDownload = inputData.getBoolean(ARG_IS_USER_INITIATED, false)
- downloader.download(bbox, isPriorityDownload)
+ downloader.download(bbox,
+ isPriorityDownload,
+ context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
+ .getBoolean(Prefs.MANUAL_DOWNLOAD_OVERRIDE_CACHE, true)
+ )
Result.success()
} catch (e: Exception) {
Result.failure()
+ } finally {
+ downloading = false
+ if (enqueuedDownloads.isNotEmpty()) {
+ val next = enqueuedDownloads.first()
+ enqueuedDownloads.removeFirstOrNull()
+ DownloadController(context).download(next, true)
+ }
}
}
companion object {
private const val ARG_BBOX = "bbox"
private const val ARG_IS_USER_INITIATED = "isUserInitiated"
+ var downloading = false
+ val enqueuedDownloads = mutableListOf()
fun createWorkRequest(bbox: BoundingBox, isUserInitiated: Boolean): OneTimeWorkRequest =
OneTimeWorkRequestBuilder()
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt
index 98a33ed096a..c3fd4f50622 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/Downloader.kt
@@ -9,6 +9,7 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader
import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader
import de.westnordost.streetcomplete.data.osmnotes.NotesDownloader
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController
import de.westnordost.streetcomplete.data.user.UserLoginController
import de.westnordost.streetcomplete.util.Listeners
import de.westnordost.streetcomplete.util.ktx.format
@@ -19,6 +20,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.yield
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.max
@@ -29,7 +31,8 @@ class Downloader(
private val mapTilesDownloader: MapTilesDownloader,
private val downloadedTilesController: DownloadedTilesController,
private val userLoginController: UserLoginController,
- private val mutex: Mutex
+ private val mutex: Mutex,
+ private val externalSourceQuestController: ExternalSourceQuestController,
) : DownloadProgressSource {
private val listeners = Listeners()
@@ -40,7 +43,7 @@ class Downloader(
override var isDownloadInProgress: Boolean = false
private set
- suspend fun download(bbox: BoundingBox, isUserInitiated: Boolean) {
+ suspend fun download(bbox: BoundingBox, isUserInitiated: Boolean, ignoreCache: Boolean) {
var hasError = false
try {
isDownloadInProgress = true
@@ -57,7 +60,7 @@ class Downloader(
).joinToString(",")
val sqkm = (tilesBbox.area() / 1000 / 1000).format(1)
- if (!isUserInitiated && hasDownloadedAlready(tiles)) {
+ if (!ignoreCache && hasDownloadedAlready(tiles)) {
Log.i(TAG, "Not downloading ($sqkm km², bbox: $bboxString), data still fresh")
return
}
@@ -69,7 +72,12 @@ class Downloader(
coroutineScope {
// all downloaders run concurrently
launch { notesDownloader.download(tilesBbox) }
- launch { mapDataDownloader.download(tilesBbox) }
+ launch {
+ mapDataDownloader.download(tilesBbox)
+ yield()
+ // download externalSource stuff after map data, because quest creation may depend on map data
+ externalSourceQuestController.download(bbox)
+ }
launch { mapTilesDownloader.download(tilesBbox) }
}
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt b/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt
index dc6a1557715..cb2aed069aa 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/download/tiles/TilesRect.kt
@@ -42,6 +42,48 @@ fun Collection.minTileRect(): TilesRect? {
return TilesRect(left, top, right, bottom)
}
+/** Returns up to two TileRects that together enclose all the tiles.
+ * These TilesRects together should contain fewer TilePos than [minTileRect].
+ * This method is specifically aimed at the SpatialCache to avoid loading the whole bbox after
+ * scrolling diagonally, and may not produce good results in other situations.
+ * If this function can't divide the Collection into two smaller TileRects, [minTileRect] is
+ * returned.
+ */
+fun Collection.upToTwoMinTileRects(): List? {
+ val minTileRect = minTileRect() ?: return null
+ if (minTileRect.size == size)
+ return listOf(minTileRect)
+
+ val grouped = groupBy { it.y } // sort tiles in lines
+ val lines = grouped.keys.sorted()
+ if (lines != (lines.min()..lines.max()).toList())
+ return listOf(minTileRect) // can't deal with missing lines
+
+ val minXTop = grouped[lines.first()]!!.minOf { it.x }
+ val maxXTop = grouped[lines.first()]!!.maxOf { it.x }
+ val minXBottom = grouped[lines.last()]!!.minOf { it.x }
+ val maxXBottom = grouped[lines.last()]!!.maxOf { it.x }
+
+ // Find the line where minX or maxX change. Typically there should be such a line, because
+ // otherwise minTileRect.size == size would return true. Exception is when there are holes,
+ // e.g. from zooming out with the center cached and thus loading a "ring".
+ val changeLine = lines.firstOrNull { line ->
+ grouped[line]!!.minOf { it.x } != minXTop || grouped[line]!!.maxOf { it.x } != maxXTop
+ } ?: return listOf(minTileRect)
+
+ // Fall back to minTileRect if there is a second change in line width. We could deal with this,
+ // but this is not a situation expected in spatialCache.
+ if ((changeLine..lines.last()).any { line ->
+ grouped[line]!!.minOf { it.x } != minXBottom || grouped[line]!!.maxOf { it.x } != maxXBottom
+ })
+ return listOf(minTileRect)
+
+ return listOf(
+ TilesRect(minXTop, lines.first(), maxXTop, changeLine-1),
+ TilesRect(minXBottom, changeLine, maxXBottom, lines.last())
+ )
+}
+
/** Returns the tile that encloses the position at the given zoom level */
fun LatLon.enclosingTilePos(zoom: Int) = TilePos(
lon2tile(((longitude + 180) % 360) - 180, zoom),
@@ -120,6 +162,6 @@ private fun lon2tile(lon: Double, zoom: Int): Int =
(numTiles(zoom) * (lon + 180.0) / 360.0).toInt()
private fun lat2tile(lat: Double, zoom: Int): Int =
- (numTiles(zoom) * (1.0 - asinh(tan(PI * lat / 180.0)) / PI) / 2.0).nextUp().toInt()
+ (numTiles(zoom) * (1.0 - asinh(tan(lat * (PI / 180.0))) / PI) / 2.0).nextUp().toInt()
private fun numTiles(zoom: Int): Int = 1 shl zoom
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt
index 872b8768260..9fb00505a97 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt
@@ -1,6 +1,7 @@
package de.westnordost.streetcomplete.data.edithistory
import de.westnordost.streetcomplete.ApplicationConstants.MAX_UNDO_HISTORY_AGE
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource
@@ -21,6 +22,9 @@ import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource
import de.westnordost.streetcomplete.util.Listeners
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestHidden
+import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey
+import de.westnordost.streetcomplete.util.logs.Log
/** All edits done by the user in one place: Edits made on notes, on map data, hidings of quests */
class EditHistoryController(
@@ -30,6 +34,7 @@ class EditHistoryController(
private val notesSource: NotesWithEditsSource,
private val mapDataSource: MapDataWithEditsSource,
private val questTypeRegistry: QuestTypeRegistry,
+ private val externalSourceQuestController: ExternalSourceQuestController,
) : EditHistorySource {
private val listeners = Listeners()
@@ -37,6 +42,9 @@ class EditHistoryController(
override fun onAddedEdit(edit: ElementEdit) {
if (edit.action !is IsRevertAction) onAdded(edit)
}
+ override fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) {
+ if (edit.action !is IsRevertAction) onSynced(edit)
+ }
override fun onSyncedEdit(edit: ElementEdit) {
if (edit.action !is IsRevertAction) onSynced(edit)
}
@@ -54,6 +62,7 @@ class EditHistoryController(
private val questHiddenListener = object : QuestsHiddenSource.Listener {
override fun onHid(key: QuestKey, timestamp: Long) {
val edit = createQuestHiddenEdit(key, timestamp)
+ if (hiddenQuestsController.get(key) == null) return // must be tempHide -> don't create an edit
if (edit != null) onAdded(edit)
}
override fun onUnhid(key: QuestKey, timestamp: Long) {
@@ -74,6 +83,11 @@ class EditHistoryController(
val questType = questTypeRegistry.getByName(key.questTypeName) as? OsmElementQuestType<*> ?: return null
OsmQuestHidden(key.elementType, key.elementId, questType, geometry, timestamp)
}
+ is ExternalSourceQuestKey -> {
+ val type = externalSourceQuestController.getQuestType(key) ?: return null
+ val quest = externalSourceQuestController.get(key) ?: return null
+ ExternalSourceQuestHidden(key.id, type, quest.position, timestamp)
+ }
}
}
@@ -91,6 +105,7 @@ class EditHistoryController(
is NoteEdit -> noteEditsController.undo(edit)
is OsmNoteQuestHidden -> hiddenQuestsController.unhide(edit.questKey)
is OsmQuestHidden -> hiddenQuestsController.unhide(edit.questKey)
+ is ExternalSourceQuestHidden -> hiddenQuestsController.unhide(edit.questKey)
else -> throw IllegalArgumentException()
}
}
@@ -134,6 +149,8 @@ class EditHistoryController(
}
private fun onAdded(edit: Edit) {
+ if (edit is ElementEdit) Log.i(TAG, "history: add edit ${edit.type.name} for ${edit.action.elementKeys}")
+ else Log.i(TAG, "history: add edit ${edit.key}")
listeners.forEach { it.onAdded(edit) }
}
private fun onSynced(edit: Edit) {
@@ -146,3 +163,5 @@ class EditHistoryController(
listeners.forEach { it.onInvalidated() }
}
}
+
+private const val TAG = "EditHistoryController"
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt
index dc19f681548..d83254fd250 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt
@@ -4,5 +4,5 @@ import org.koin.dsl.module
val editHistoryModule = module {
single { get() }
- single { EditHistoryController(get(), get(), get(), get(), get(), get()) }
+ single { EditHistoryController(get(), get(), get(), get(), get(), get(), get()) }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt
index 60e4c3f9610..89d7de081bd 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/ElementFiltersParser.kt
@@ -231,7 +231,7 @@ private fun StringWithCursor.parseElementFilter(): ElementFilter {
return ElementNewerThan(parseDateFilter())
}
- val key = parseTag()
+ val key = parseTag().intern()
val operator = parseOperatorWithSurroundingSpaces() ?: return HasKey(key)
if (operator == OLDER) {
@@ -244,8 +244,8 @@ private fun StringWithCursor.parseElementFilter(): ElementFilter {
if (operator in KEY_VALUE_OPERATORS) {
val value = parseTag()
when (operator) {
- EQUALS -> return HasTag(key, value)
- NOT_EQUALS -> return NotHasTag(key, value)
+ EQUALS -> return HasTag(key, value.intern())
+ NOT_EQUALS -> return NotHasTag(key, value.intern())
LIKE -> return HasTagValueLike(key, value)
NOT_LIKE -> return NotHasTagValueLike(key, value)
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt
index e4ab2a6b1b6..270ad08afec 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/ElementFilter.kt
@@ -131,7 +131,11 @@ abstract class CompareDateTagValue(val key: String, val dateFilter: DateFilter)
class TagOlderThan(key: String, dateFilter: DateFilter) : CompareTagAge(key, dateFilter) {
override fun toString() = "$key older $dateFilter"
- override fun compareTo(tagValue: LocalDate) = tagValue < dateFilter.date
+ override fun compareTo(tagValue: LocalDate) =
+ if (resurveyDate != null && resurveyKeys.contains(key))
+ tagValue < resurveyDate!!
+ else
+ tagValue < dateFilter.date
}
class TagNewerThan(key: String, dateFilter: DateFilter) : CompareTagAge(key, dateFilter) {
override fun toString() = "$key newer $dateFilter"
@@ -142,9 +146,21 @@ abstract class CompareTagAge(val key: String, val dateFilter: DateFilter) : Elem
abstract fun compareTo(tagValue: LocalDate): Boolean
override fun matches(obj: Element): Boolean {
if (compareTo(Instant.fromEpochMilliseconds(obj.timestampEdited).toLocalDate())) return true
- return getLastCheckDateKeys(key)
- .mapNotNull { obj.tags[it]?.toCheckDate() }
- .any { compareTo(it) }
+ return if (resurveyKeys.contains(key)) {
+ // if we have a tag in the resurvey list, interpret missing check date as match
+ val l = getLastCheckDateKeys(key)
+ .mapNotNull { obj.tags[it]?.toCheckDate() }.toList()
+ if (l.isEmpty()) true
+ else l.any { compareTo(it) }
+ } else
+ getLastCheckDateKeys(key)
+ .mapNotNull { obj.tags[it]?.toCheckDate() }
+ .any { compareTo(it) }
+ }
+
+ companion object {
+ var resurveyKeys = listOf()
+ var resurveyDate: LocalDate? = null
}
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt
index f88be865c8d..8ca4e2e5971 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/elementfilter/filters/RegexOrSet.kt
@@ -10,7 +10,8 @@ sealed class RegexOrSet {
fun from(string: String): RegexOrSet =
if (!string.contains(anyRegexStuffExceptPipe)) {
- SetRegex(string.split('|').toSet())
+ val split = string.split('|')
+ SetRegex(HashSet(split.size, 0.9f).apply { split.forEach { add(it.intern()) } })
} else {
RealRegex(string.toRegex())
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt
new file mode 100644
index 00000000000..484ec818806
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceDao.kt
@@ -0,0 +1,96 @@
+package de.westnordost.streetcomplete.data.externalsource
+
+import de.westnordost.streetcomplete.data.CursorPosition
+import de.westnordost.streetcomplete.data.Database
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.EDIT_ID
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.ID
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.SOURCE
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.Columns.TIMESTAMP
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.NAME_EDITS
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestTables.NAME_HIDDEN
+import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+
+class ExternalSourceDao(private val db: Database) {
+
+ fun addElementEdit(key: ExternalSourceQuestKey, elementEditId: Long) {
+ db.insert(NAME_EDITS, listOf(
+ EDIT_ID to elementEditId,
+ ID to key.id,
+ SOURCE to key.source
+ ))
+ }
+
+ fun getKeyForElementEdit(elementEditId: Long): ExternalSourceQuestKey? =
+ db.queryOne(NAME_EDITS, where = "$EDIT_ID = $elementEditId") { it.toKey() }
+
+ fun deleteElementEdit(elementEditId: Long) =
+ db.delete(NAME_EDITS, where = "$EDIT_ID = $elementEditId") > 0
+
+ fun deleteAllExceptForElementEdits(elementEditIds: Collection) =
+ db.delete(NAME_EDITS, where = "$EDIT_ID not in (${elementEditIds.joinToString(",")})") > 0
+}
+
+class ExternalSourceHiddenDao(private val db: Database) {
+
+ fun add(key: ExternalSourceQuestKey): Long {
+ val timestamp = nowAsEpochMilliseconds()
+ val inserted = db.insert(NAME_HIDDEN, listOf(
+ ID to key.id,
+ SOURCE to key.source,
+ TIMESTAMP to timestamp
+ )) > 0
+ return if (inserted) timestamp else 0L
+ }
+
+ fun getTimestamp(key: ExternalSourceQuestKey): Long? =
+ db.queryOne(NAME_HIDDEN,
+ where = "$ID = '${key.id}' AND $SOURCE = '${key.source}'",
+ columns = arrayOf(TIMESTAMP)
+ ) { it.getLong(TIMESTAMP) }
+
+ fun delete(key: ExternalSourceQuestKey) =
+ db.delete(NAME_HIDDEN, where = "$ID = '${key.id}' AND $SOURCE = '${key.source}'") > 0
+
+ fun getAllHiddenNewerThan(timestamp: Long): List> =
+ db.query(NAME_HIDDEN, where = "$TIMESTAMP > $timestamp") { it.toKey() to it.getLong(TIMESTAMP) }
+
+ fun getAll(): List> =
+ db.query(NAME_HIDDEN, columns = arrayOf(ID, SOURCE, TIMESTAMP)) { it.toKey() to it.getLong(TIMESTAMP) }
+
+ fun deleteAll() = db.delete(NAME_HIDDEN)
+}
+
+private fun CursorPosition.toKey() = ExternalSourceQuestKey(getString(ID), getString(SOURCE))
+
+object ExternalSourceQuestTables {
+ const val NAME_HIDDEN = "other_source_hidden"
+ const val NAME_EDITS = "other_source_edits"
+
+ object Columns {
+ const val ID = "id"
+ const val SOURCE = "source"
+ const val TIMESTAMP = "timestamp" // hidden only
+ const val EDIT_ID = "edit_id" // edits only
+ }
+
+ const val CREATE_HIDDEN = """
+ CREATE TABLE IF NOT EXISTS $NAME_HIDDEN (
+ $ID TEXT,
+ $SOURCE TEXT,
+ $TIMESTAMP int NOT NULL,
+ PRIMARY KEY (
+ $ID,
+ $SOURCE
+ )
+ );
+ """
+
+ const val CREATE_EDITS = """
+ CREATE TABLE IF NOT EXISTS $NAME_EDITS (
+ $EDIT_ID INTEGER PRIMARY KEY,
+ $ID TEXT,
+ $SOURCE TEXT
+ );
+ """
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt
new file mode 100644
index 00000000000..e32e135bffb
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceModule.kt
@@ -0,0 +1,25 @@
+package de.westnordost.streetcomplete.data.externalsource
+
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val externalSourceModule = module {
+ single { ExternalSourceQuestController(get(named("CountryBoundariesLazy")), get(), get(), get()) }
+ single { ExternalSourceDao(get()) }
+ single { ExternalSourceHiddenDao(get()) }
+}
+
+// todo: if a quest doesn't lead to an elementEdit, currently there is no way to undo
+// -> implement for things like undoing false positive in osmose quest
+/*
+class ExternalSourceEditKey(val source: String, val id: Long) : EditKey() // have a key for each source that needs it?
+data class ExternalSourceEdit(
+ override val position: LatLon,
+ override val isSynced: Boolean?,
+ val action: Unit, // depending on the source and what was done, need some type...
+) : Edit {
+ override val key: ExternalSourceEditKey
+ override val createdTimestamp: Long
+ override val isUndoable: Boolean
+}
+*/
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt
new file mode 100644
index 00000000000..e4cbcd85d18
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuest.kt
@@ -0,0 +1,36 @@
+package de.westnordost.streetcomplete.data.externalsource
+
+import de.westnordost.streetcomplete.data.edithistory.Edit
+import de.westnordost.streetcomplete.data.edithistory.QuestHiddenKey
+import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey
+import de.westnordost.streetcomplete.data.quest.Quest
+
+data class ExternalSourceQuest(
+ /** Each quest must be uniquely identified by the [id] and [source] */
+ val id: String,
+ override val geometry: ElementGeometry,
+ override val type: ExternalSourceQuestType,
+ override val position: LatLon = geometry.center // allow setting position to arbitrary LatLon, because e.g. Osmose issues are often located at outline of building instead of in center
+) : Quest() {
+ override val key = ExternalSourceQuestKey(id, source)
+ override val markerLocations: Collection get() = listOf(position)
+ val source get() = type.source
+
+ /** an element can be linked to the quest, but this is not necessary */
+ var elementKey: ElementKey? = null
+}
+
+data class ExternalSourceQuestHidden(
+ val id: String,
+ val questType: ExternalSourceQuestType,
+ override val position: LatLon,
+ override val createdTimestamp: Long
+) : Edit {
+ val questKey get() = ExternalSourceQuestKey(id, questType.source)
+ override val key: QuestHiddenKey get() = QuestHiddenKey(questKey)
+ override val isUndoable: Boolean get() = true
+ override val isSynced: Boolean? get() = null
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt
new file mode 100644
index 00000000000..4f6669a3f54
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestController.kt
@@ -0,0 +1,144 @@
+package de.westnordost.streetcomplete.data.externalsource
+
+import de.westnordost.countryboundaries.CountryBoundaries
+import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.quest.ExternalSourceQuestKey
+import de.westnordost.streetcomplete.data.quest.QuestKey
+import de.westnordost.streetcomplete.data.quest.QuestType
+import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry
+import de.westnordost.streetcomplete.util.ktx.intersects
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import java.util.concurrent.CopyOnWriteArrayList
+
+class ExternalSourceQuestController(
+ private val countryBoundaries: Lazy,
+ private val questTypeRegistry: QuestTypeRegistry,
+ private val externalSourceDao: ExternalSourceDao,
+ elementEditsController: ElementEditsController,
+) : ElementEditsSource.Listener {
+
+ interface QuestListener {
+ fun onUpdated(addedQuests: Collection = emptyList(), deletedQuestKeys: Collection = emptyList())
+ fun onInvalidate()
+ }
+ private val questListeners: MutableList = CopyOnWriteArrayList()
+ fun addQuestListener(questListener: QuestListener) {
+ questListeners.add(questListener)
+ }
+
+ private val questTypes get() = questTypeRegistry.filterIsInstance()
+ private val questTypeNamesBySource by lazy {
+ val types = questTypes
+ val namesBySource = types.associate { it.source to it.name }
+ if (types.size != namesBySource.size)
+ throw IllegalStateException("source values must be unique")
+ namesBySource
+ }
+ private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ init { elementEditsController.addListener(this) }
+
+ fun delete(key: ExternalSourceQuestKey) {
+ getQuestType(key)?.deleteQuest(key.id)
+ questListeners.forEach { it.onUpdated(deletedQuestKeys = listOf(key)) }
+ }
+
+ fun getAllInBBox(bbox: BoundingBox, visibleQuestTypes: List? = null): List {
+ return (visibleQuestTypes?.filterIsInstance() ?: questTypes)
+ .flatMap { it.getQuests(bbox) }
+ }
+
+ fun get(key: ExternalSourceQuestKey): ExternalSourceQuest? = getQuestType(key)?.get(key.id)
+
+ /** calls [download] for each [ExternalSourceQuestType] enabled in this country, thus may take long */
+ suspend fun download(bbox: BoundingBox) {
+ withContext(Dispatchers.IO) {
+ val countryBoundaries = countryBoundaries.value
+ val updates = questTypes.mapNotNull { type ->
+ if (!type.downloadEnabled) return@mapNotNull null
+ if (!countryBoundaries.intersects(bbox, type.enabledInCountries)) return@mapNotNull null
+ scope.async {
+ val previousQuests = type.getQuests(bbox).map { it.key }
+ val quests = type.download(bbox)
+ val questKeys = HashSet(quests.size).apply { quests.forEach { add(it.key) } }
+ quests to previousQuests.filterNot { it in questKeys }
+ }
+ }.awaitAll().unzip()
+ val newQuests = updates.first.flatten()
+ val obsoleteQuestKeys = updates.second.flatten()
+ questListeners.forEach { it.onUpdated(newQuests, obsoleteQuestKeys) }
+ }
+ }
+
+ /** calls [upload] for each [ExternalSourceQuestType], thus may take long */
+ suspend fun upload() = questTypes.forEach { it.upload() }
+
+ fun invalidate() = questListeners.forEach { it.onInvalidate() }
+
+ /** to be called if quests have been added outside a download, so they can be shown immediately */
+ fun addQuests(quests: Collection) =
+ questListeners.forEach { it.onUpdated(addedQuests = quests) }
+
+ // ElementEditsListener
+
+ // ignore, and actually this should never be called for ExternalSourceQuestType
+ override fun onAddedEdit(edit: ElementEdit) {}
+
+ override fun onAddedEdit(edit: ElementEdit, key: QuestKey?) {
+ if (key is ExternalSourceQuestKey) {
+ getQuestType(key)?.onAddedEdit(edit, key.id)
+ externalSourceDao.addElementEdit(key, edit.id)
+ questListeners.forEach { it.onUpdated(deletedQuestKeys = listOf(key)) }
+ }
+ }
+
+ override fun onSyncedEdit(edit: ElementEdit) {
+ val type = edit.type as? ExternalSourceQuestType ?: return
+ val key = externalSourceDao.getKeyForElementEdit(edit.id)
+ // don't delete synced edits, this will be done by ElementEditsController (using onDeletedEdits)
+ type.onSyncedEdit(edit, key?.id)
+ }
+
+ fun onSyncEditFailed(edit: ElementEdit) {
+ val type = edit.type as? ExternalSourceQuestType ?: return
+ val key = externalSourceDao.getKeyForElementEdit(edit.id)
+ type.onSyncEditFailed(edit, key?.id)
+ }
+
+ suspend fun onUpload(edit: ElementEdit): Boolean {
+ val type = edit.type as? ExternalSourceQuestType ?: return true
+ val key = externalSourceDao.getKeyForElementEdit(edit.id)
+ return type.onUpload(edit, key?.id)
+ }
+
+ // for undoing stuff
+ override fun onDeletedEdits(edits: List) {
+ val restoredQuests = edits.mapNotNull { edit ->
+ if (edit.isSynced) return@mapNotNull null // synced edits are deleted after 12 hours, and we don't want this to restore anything
+ val key = externalSourceDao.getKeyForElementEdit(edit.id)
+ externalSourceDao.deleteElementEdit(edit.id)
+ val type = questTypeNamesBySource[key?.source]?.let { questTypeRegistry.getByName(it) } as? ExternalSourceQuestType
+ type?.onDeletedEdit(edit, key?.id)
+ if (key == null) null
+ else get(key)
+ }
+ questListeners.forEach { it.onUpdated(addedQuests = restoredQuests) }
+ }
+
+ // called when old elementEdits are removed
+ fun cleanElementEdits(elementEditsIds: Collection) =
+ externalSourceDao.deleteAllExceptForElementEdits(elementEditsIds)
+
+ fun getQuestType(key: ExternalSourceQuestKey): ExternalSourceQuestType? {
+ val questTypeName = questTypeNamesBySource[key.source] ?: return null
+ return questTypeRegistry.getByName(questTypeName) as? ExternalSourceQuestType
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt
new file mode 100644
index 00000000000..162614e522c
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/externalsource/ExternalSourceQuestType.kt
@@ -0,0 +1,115 @@
+package de.westnordost.streetcomplete.data.externalsource
+
+import android.content.Context
+import androidx.appcompat.app.AlertDialog
+import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditType
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.Element
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry
+import de.westnordost.streetcomplete.data.quest.AllCountries
+import de.westnordost.streetcomplete.data.quest.Countries
+import de.westnordost.streetcomplete.data.quest.QuestType
+import de.westnordost.streetcomplete.quests.questPrefix
+
+/**
+ * Very similar to OsmElementQuestType.
+ *
+ * Each quest type is responsible for downloading and persisting data, and uploading any changes if applicable.
+ * ElementEdits are uploaded automatically, and onSyncedEdit is called after each uploaded ElementEdit.
+ * Changes uploaded through [upload] may be things like reporting false positives for Osmose.
+ *
+ * [deleteMetadataOlderThan] is called every day and should be used to remove old data.
+ */
+// do it very similar to OsmElementQuestType
+// for cleanup, each quest type should override deleteMetadataOlderThan, or old data will remain
+interface ExternalSourceQuestType : QuestType, ElementEditType {
+ // like for OsmQuestType
+ override val title: Int get() = getTitle(emptyMap())
+ fun getTitle(tags: Map): Int
+ val highlightedElementsRadius: Double get() = 30.0
+ fun getHighlightedElements(getMapData: () -> MapDataWithGeometry): Sequence = emptySequence()
+ val enabledInCountries: Countries get() = AllCountries
+
+ /** Unique string for each source (app will crash on start if sources are not unique). */
+ val source: String
+
+ /**
+ * Download and persist data, create quests inside the given [bbox] and return the new quests.
+ * Download date should be stored for each entry to allow cleanup of old data.
+ * It's probably a good idea to remove old data inside the [bbox] before inserting updates.
+ *
+ * Download will only happen if [downloadEnabled] is true.
+ */
+ suspend fun download(bbox: BoundingBox): Collection
+
+ /**
+ * Upload changes to the server. Uploaded quests should not be created again on [download].
+ * Note that on each individual upload of an ElementEdit, [onUpload] will be called before
+ * uploading this edit, and [onSyncedEdit] will be called after, if there is a connected ElementEdit.
+ * [upload] is called only after all elementEdits.
+ */
+ suspend fun upload()
+
+ /** Return all quests inside the given [bbox]. This should be fast and not require internet access. */
+ fun getQuests(bbox: BoundingBox): Collection
+
+ /** Return quest with the given [id], or null. */
+ fun get(id: String): ExternalSourceQuest?
+
+ /**
+ * Called if an ElementEdit was done as part of solving the quest with the given [id].
+ * Actions should be taken so the quest for [id] does not appear again.
+ */
+ fun onAddedEdit(edit: ElementEdit, id: String)
+
+ /**
+ * Called if the ElementEdit done as part of quest with the given [id] was deleted.
+ * This can be because
+ * an edit was undone (before or after upload)
+ * it was already uploaded and removed because it is older than MAX_UNDO_HISTORY_AGE
+ * uploading the edit failed with a conflict exception (in this case onSyncEditFailed is called first)
+ * [id] can be null in case edit was not properly associated with id.
+ */
+ fun onDeletedEdit(edit: ElementEdit, id: String?)
+
+ /**
+ * Called if the ElementEdit done as part of quest with the given [id] was synced (uploaded).
+ * [id] can be null in case edit was not properly associated with id.
+ * Note that [upload] will also be called (before the first edit upload).
+ */
+ fun onSyncedEdit(edit: ElementEdit, id: String?)
+
+ /** Uploading the [edit] has failed due to a conflict exception */
+ fun onSyncEditFailed(edit: ElementEdit, id: String?)
+
+ /**
+ * Called before uploading [edit]. Uploading will wait until this function returns.
+ * @return false to cancel the upload for this edit (will throw a conflict exception)
+ */
+ suspend fun onUpload(edit: ElementEdit, id: String?): Boolean
+
+ /**
+ * Removes the quest with the given [id]. What happens internally doesn't matter, as long as
+ * the quest doesn't show up again when using [get] or [getQuests].
+ */
+ fun deleteQuest(id: String): Boolean
+
+ /**
+ * Necessary to clean old data.
+ * Will be called with (nearly) current time when clearing all stored data is desired.
+ */
+ override fun deleteMetadataOlderThan(timestamp: Long)
+
+ /** quest settings should always exist, at least to control [downloadEnabled] */
+ override val hasQuestSettings get() = true
+
+ override fun getQuestSettingsDialog(context: Context): AlertDialog?
+
+ /** disabled by default, so either this must be enabled manually or overridden */
+ var downloadEnabled: Boolean
+ get() = prefs.getBoolean(downloadPref, false)
+ set(value) = prefs.edit().putBoolean(downloadPref, value).apply()
+
+ private val downloadPref get() = questPrefix(prefs) + "qs_${name}_enable_download"
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt
index f146fcb4a21..96d5de300c8 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/AddElementEditsController.kt
@@ -1,6 +1,7 @@
package de.westnordost.streetcomplete.data.osm.edits
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
+import de.westnordost.streetcomplete.data.quest.QuestKey
interface AddElementEditsController {
fun add(
@@ -8,6 +9,7 @@ interface AddElementEditsController {
geometry: ElementGeometry,
source: String,
action: ElementEditAction,
- isNearUserLocation: Boolean
+ isNearUserLocation: Boolean,
+ key: QuestKey? = null
)
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt
index 4c30e28b8db..f658da7db0d 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/EditType.kt
@@ -20,7 +20,7 @@ interface EditType {
val wikiLink: String?
/** towards which achievements solving an edit of this type should count */
- val achievements: List
+ val achievements: List get() = emptyList()
/** the string resource id that explains why this edit type is disabled by default or zero if it
* is not disabled by default.
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt
index 1580ce4047e..7230f4b2528 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsController.kt
@@ -1,24 +1,47 @@
package de.westnordost.streetcomplete.data.osm.edits
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
import de.westnordost.streetcomplete.data.preferences.Preferences
+import de.westnordost.streetcomplete.data.quest.QuestKey
import de.westnordost.streetcomplete.util.Listeners
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import de.westnordost.streetcomplete.util.logs.Log
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
class ElementEditsController(
private val editsDB: ElementEditsDao,
private val editElementsDB: EditElementsDao,
private val elementIdProviderDB: ElementIdProviderDao,
- private val prefs: Preferences
-) : ElementEditsSource, AddElementEditsController {
+ private val prefs: Preferences,
+// private val externalSourceQuestController: ExternalSourceQuestController,
+) : ElementEditsSource, AddElementEditsController, KoinComponent {
/* Must be a singleton because there is a listener that should respond to a change in the
* database table */
-
+ private val externalSourceQuestController: ExternalSourceQuestController by inject()
private val listeners = Listeners()
+ private val editCache by lazy {
+ val c = hashMapOf()
+ editsDB.getAll().associateByTo(c) { it.id }
+ }
+
+ // full elementIdProvider cache didn't work as expected, so only store empty idPoviders (resp. their ids)
+ // this is still very useful, because
+ // most are actually empty (edit tags action)
+ // on rebuildLocalChanges idProviders of all edits are queried, so the cache saves many db queries
+ // each query is fast, but for many unsynced edits this is a clear improvement
+ private val emptyIdProviderCache = HashSet()
+
/* ----------------------- Unsynced edits and syncing them -------------------------------- */
/** Add new unsynced edit to the to-be-uploaded queue */
@@ -27,26 +50,41 @@ class ElementEditsController(
geometry: ElementGeometry,
source: String,
action: ElementEditAction,
- isNearUserLocation: Boolean
+ isNearUserLocation: Boolean,
+ key: QuestKey?
) {
Log.d(TAG, "Add ${type.name} for ${action.elementKeys.joinToString()}")
- add(ElementEdit(0, type, geometry, source, nowAsEpochMilliseconds(), false, action, isNearUserLocation))
+ // removes discardable tags if they were part of original element, but not if user added them
+ val newAction = if (action is UpdateElementTagsAction && action.originalElement.tags.keys.any { it in DISCARDABLE_TAGS }) {
+ val builder = StringMapChangesBuilder(action.originalElement.tags)
+ action.changes.changes.forEach { when (it) {
+ is StringMapEntryDelete -> builder.remove(it.key)
+ is StringMapEntryAdd -> builder[it.key] = it.value
+ is StringMapEntryModify -> builder[it.key] = it.value
+ } }
+ DISCARDABLE_TAGS.forEach { builder.remove(it) }
+ UpdateElementTagsAction(action.originalElement, builder.create())
+ } else
+ action
+ add(ElementEdit(0, type, geometry, source, nowAsEpochMilliseconds(), false, newAction, isNearUserLocation), key)
}
- override fun get(id: Long): ElementEdit? =
- editsDB.get(id)
+ override fun get(id: Long): ElementEdit? = synchronized(this) { editCache[id] }
- override fun getAll(): List =
- editsDB.getAll()
+ override fun getAll(): List = synchronized(this) { editCache.values.toList() }
override fun getAllUnsynced(): List =
- editsDB.getAllUnsynced()
+ getAll().filterNot { it.isSynced }
fun getOldestUnsynced(): ElementEdit? =
- editsDB.getOldestUnsynced()
+ getAllUnsynced().minByOrNull { it.createdTimestamp }
- fun getIdProvider(id: Long): ElementIdProvider =
- elementIdProviderDB.get(id)
+ fun getIdProvider(id: Long): ElementIdProvider = synchronized(emptyIdProviderCache) {
+ if (emptyIdProviderCache.contains(id)) return ElementIdProvider(emptyList())
+ val p = elementIdProviderDB.get(id)
+ if (p.isEmpty()) emptyIdProviderCache.add(id)
+ return p
+ }
/** Delete old synced (aka uploaded) edits older than the given timestamp. Used to clear
* the undo history */
@@ -54,11 +92,15 @@ class ElementEditsController(
val deletedCount: Int
val deleteEdits: List
synchronized(this) {
- deleteEdits = editsDB.getSyncedOlderThan(timestamp)
+ val allEdits = editsDB.getAll()
+ deleteEdits = allEdits.filter { it.createdTimestamp < timestamp && it.isSynced }
if (deleteEdits.isEmpty()) return 0
val ids = deleteEdits.map { it.id }
+ editCache.keys.removeAll(ids)
deletedCount = editsDB.deleteAll(ids)
editElementsDB.deleteAll(ids)
+ val keep = allEdits.filter { it.type is ExternalSourceQuestType && (it.isSynced || it.createdTimestamp >= timestamp) }
+ externalSourceQuestController.cleanElementEdits(keep.map { it.id })
}
onDeletedEdits(deleteEdits)
/* must be deleted after the callback because the callback might want to get the id provider
@@ -68,10 +110,10 @@ class ElementEditsController(
}
override fun getUnsyncedCount(): Int =
- editsDB.getUnsyncedCount()
+ getAllUnsynced().size
override fun getPositiveUnsyncedCount(): Int {
- val unsynced = editsDB.getAllUnsynced().map { it.action }
+ val unsynced = getAllUnsynced().map { it.action }
return unsynced.filter { it !is IsRevertAction }.size - unsynced.filter { it is IsRevertAction }.size
}
@@ -80,22 +122,32 @@ class ElementEditsController(
ElementKey(it.elementType, it.oldElementId) to it.newElementId
}
val syncSuccess: Boolean
+ val editIdsToUpdate = HashSet()
+ val syncedEdit by lazy { edit.copy(isSynced = true) }
synchronized(this) {
- val editIdsToUpdate = elementUpdates.idUpdates.flatMapTo(HashSet()) {
+ elementUpdates.idUpdates.flatMapTo(editIdsToUpdate) {
editElementsDB.getAllByElement(it.elementType, it.oldElementId)
}
for (id in editIdsToUpdate) {
val oldEdit = editsDB.get(id) ?: continue
val updatedEdit = oldEdit.copy(action = oldEdit.action.idsUpdatesApplied(idUpdatesMap))
editsDB.put(updatedEdit)
+ editCache[updatedEdit.id] = updatedEdit
// must clear first because the element ids associated with this id are different now
editElementsDB.delete(id)
editElementsDB.put(id, updatedEdit.action.elementKeys)
}
+ if (editIdsToUpdate.isNotEmpty())
+ synchronized(emptyIdProviderCache) { emptyIdProviderCache.removeAll(editIdsToUpdate) }
syncSuccess = editsDB.markSynced(edit.id)
+
+ if (syncSuccess)
+ editCache[edit.id] = syncedEdit
}
- if (syncSuccess) onSyncedEdit(edit.copy(isSynced = true))
+
+ if (syncSuccess) onSyncedEdit(syncedEdit, editIdsToUpdate) // forward which ids were updated, because history controller needs to reload those edits
elementIdProviderDB.updateIds(elementUpdates.idUpdates)
+ synchronized(emptyIdProviderCache) { emptyIdProviderCache.remove(edit.id) }
}
fun markSyncFailed(edit: ElementEdit) {
@@ -128,7 +180,7 @@ class ElementEditsController(
/* ------------------------------------ add/sync/delete ------------------------------------- */
- private fun add(edit: ElementEdit) {
+ private fun add(edit: ElementEdit, key: QuestKey? = null) {
synchronized(this) {
editsDB.put(edit)
editElementsDB.put(edit.id, edit.action.elementKeys)
@@ -139,8 +191,9 @@ class ElementEditsController(
createdElementsCount.ways,
createdElementsCount.relations
)
+ editCache[edit.id] = edit
}
- onAddedEdit(edit)
+ onAddedEdit(edit, key)
}
private fun delete(edit: ElementEdit) {
@@ -153,12 +206,14 @@ class ElementEditsController(
editsDB.deleteAll(ids)
editElementsDB.deleteAll(ids)
+ editCache.keys.removeAll(ids)
}
onDeletedEdits(edits)
/* must be deleted after the callback because the callback might want to get the id provider
for that edit */
+ synchronized(emptyIdProviderCache) { ids.forEach { emptyIdProviderCache.remove(it) } }
elementIdProviderDB.deleteAll(ids)
}
@@ -168,7 +223,7 @@ class ElementEditsController(
val createdElementKeys = elementIdProviderDB.get(edit.id).getAll()
val editsBasedOnThese = createdElementKeys
.flatMapTo(HashSet()) { editElementsDB.getAllByElement(it.type, it.id) }
- .mapNotNull { editsDB.get(it) }
+ .mapNotNull { editCache[it] }
.filter { it.id != edit.id }
// deep first
@@ -189,13 +244,13 @@ class ElementEditsController(
listeners.remove(listener)
}
- private fun onAddedEdit(edit: ElementEdit) {
+ private fun onAddedEdit(edit: ElementEdit, key: QuestKey?) {
prefs.lastEditTime = nowAsEpochMilliseconds()
- listeners.forEach { it.onAddedEdit(edit) }
+ listeners.forEach { it.onAddedEdit(edit, key) }
}
- private fun onSyncedEdit(edit: ElementEdit) {
- listeners.forEach { it.onSyncedEdit(edit) }
+ private fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) {
+ listeners.forEach { it.onSyncedEdit(edit, updatedEditIds) }
}
private fun onDeletedEdits(edits: List) {
@@ -206,3 +261,57 @@ class ElementEditsController(
private const val TAG = "ElementEditsController"
}
}
+
+// list from josm
+private val DISCARDABLE_TAGS = hashSetOf(
+ "created_by",
+ "converted_by",
+ "current_id",
+ "geobase:datasetName",
+ "geobase:uuid",
+ "KSJ2:ADS",
+ "KSJ2:ARE",
+ "KSJ2:AdminArea",
+ "KSJ2:COP_label",
+ "KSJ2:DFD",
+ "KSJ2:INT",
+ "KSJ2:INT_label",
+ "KSJ2:LOC",
+ "KSJ2:LPN",
+ "KSJ2:OPC",
+ "KSJ2:PubFacAdmin",
+ "KSJ2:RAC",
+ "KSJ2:RAC_label",
+ "KSJ2:RIC",
+ "KSJ2:RIN",
+ "KSJ2:WSC",
+ "KSJ2:coordinate",
+ "KSJ2:curve_id",
+ "KSJ2:curve_type",
+ "KSJ2:filename",
+ "KSJ2:lake_id",
+ "KSJ2:lat",
+ "KSJ2:long",
+ "KSJ2:river_id",
+ "odbl",
+ "odbl:note",
+ "osmarender:nameDirection",
+ "osmarender:renderName",
+ "osmarender:renderRef",
+ "osmarender:rendernames",
+ "SK53_bulk:load",
+ "sub_sea:type",
+ "tiger:source",
+ "tiger:separated",
+ "tiger:tlid",
+ "tiger:upload_uuid",
+ "import_uuid",
+ "gnis:import_uuid",
+ "yh:LINE_NAME",
+ "yh:LINE_NUM",
+ "yh:STRUCTURE",
+ "yh:TOTYUMONO",
+ "yh:TYPE",
+ "yh:WIDTH",
+ "yh:WIDTH_RANK"
+)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt
index a6bcdae6dca..a23aa71b463 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsDao.kt
@@ -24,8 +24,11 @@ import de.westnordost.streetcomplete.data.osm.edits.move.RevertMoveNodeAction
import de.westnordost.streetcomplete.data.osm.edits.split_way.SplitWayAction
import de.westnordost.streetcomplete.data.osm.edits.update_tags.RevertUpdateElementTagsAction
import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
+import de.westnordost.streetcomplete.data.osm.edits.create.CreateRelationAction
+import de.westnordost.streetcomplete.data.osm.edits.delete.DeleteRelationAction
+import de.westnordost.streetcomplete.data.osm.edits.delete.RevertDeleteRelationAction
+import de.westnordost.streetcomplete.quests.tagEdit
+import de.westnordost.streetcomplete.screens.main.bottom_sheet.addNodeEdit
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@@ -48,6 +51,9 @@ class ElementEditsDao(
subclass(MoveNodeAction::class)
subclass(RevertMoveNodeAction::class)
subclass(CreateNodeFromVertexAction::class)
+ subclass(CreateRelationAction::class)
+ subclass(DeleteRelationAction::class)
+ subclass(RevertDeleteRelationAction::class)
}
}
}
@@ -108,7 +114,9 @@ class ElementEditsDao(
private fun CursorPosition.toElementEdit() = ElementEdit(
getLong(ID),
- allEditTypes.getByName(getString(QUEST_TYPE)) as ElementEditType,
+ allEditTypes.getByName(getString(QUEST_TYPE)) as? ElementEditType
+ ?: addNodeEdit.takeIf { getString(QUEST_TYPE) == addNodeEdit.name }
+ ?: tagEdit, // always assume it's a tagEdit if nothing matches, to avoid crashes if SCEE quests are removed
json.decodeFromString(getString(GEOMETRY)),
getString(SOURCE),
getLong(CREATED_TIMESTAMP),
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt
index c75536e62b4..85f5c39bf89 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsModule.kt
@@ -20,7 +20,7 @@ val elementEditsModule = module {
single { OpenChangesetsManager(get(), get(), get(), get()) }
- single { ElementEditsUploader(get(), get(), get(), get(), get(), get()) }
+ single { ElementEditsUploader(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
single { get() }
single { ElementEditsController(get(), get(), get(), get()) }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt
index 75886fe1238..3ecc4b650ac 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/ElementEditsSource.kt
@@ -1,10 +1,14 @@
package de.westnordost.streetcomplete.data.osm.edits
+import de.westnordost.streetcomplete.data.quest.QuestKey
+
interface ElementEditsSource {
/** Interface to be notified of new or updated OSM elements */
interface Listener {
fun onAddedEdit(edit: ElementEdit)
+ fun onAddedEdit(edit: ElementEdit, key: QuestKey?) = onAddedEdit(edit) // need to do it this weird way, otherwise must change everything that implement listener
fun onSyncedEdit(edit: ElementEdit)
+ fun onSyncedEdit(edit: ElementEdit, updatedEditIds: Collection) = onSyncedEdit(edit) // see above
// may be several because deleting one element edit leads to the deletion of all edits that
// are based on that edit. E.g. splitting a way, then editing the newly created way segments
fun onDeletedEdits(edits: List)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
index 764da5a62cc..dba93a1a15e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/MapDataWithEditsSource.kt
@@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.data.osm.edits
import de.westnordost.streetcomplete.data.ConflictException
import de.westnordost.streetcomplete.data.osm.edits.move.MoveNodeAction
import de.westnordost.streetcomplete.data.osm.edits.move.RevertMoveNodeAction
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTagsAction
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryCreator
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry
@@ -26,8 +27,8 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapDataWithGeometry
import de.westnordost.streetcomplete.data.osm.mapdata.Node
import de.westnordost.streetcomplete.data.osm.mapdata.Relation
import de.westnordost.streetcomplete.data.osm.mapdata.Way
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.Listeners
+import de.westnordost.streetcomplete.util.logs.Log
import de.westnordost.streetcomplete.util.math.contains
import de.westnordost.streetcomplete.util.math.intersect
@@ -283,7 +284,7 @@ class MapDataWithEditsSource internal constructor(
}
private fun getWayNodes(way: Way): Collection? = synchronized(this) {
- val ids = way.nodeIds.toSet()
+ val ids = way.nodeIds.toHashSet()
val nodes = getNodes(ids)
// If the way is (now) not complete, this is not acceptable
@@ -489,7 +490,12 @@ class MapDataWithEditsSource internal constructor(
val key = element.key
deletedElements.remove(key)
updatedElements[key] = element
- updatedGeometries[key] = createGeometry(element)
+ updatedGeometries[key] = if (element !is Node && edit.action is UpdateElementTagsAction && updates.size == 1)
+ // fetch geometry if only tags were updated (size == 1 should always be true, but better be safe)
+ // but don't fetch if it's a node, because fetching usually isn't faster than creating, unless we have a node geometry cache
+ getGeometry(element.type, element.id)
+ else
+ createGeometry(element)
}
return MapDataUpdates(updated = updates, deleted = deletedKeys)
@@ -537,6 +543,8 @@ class MapDataWithEditsSource internal constructor(
private fun callOnUpdated(updated: MapDataWithGeometry, deleted: Collection) {
if (updated.size == 0 && deleted.isEmpty()) return
+ if (updated.size > 10 || deleted.size > 10) Log.i(TAG, "updated ${updated.size}, deleted ${deleted.size}")
+ else Log.i(TAG, "updated: ${updated.map { it.key }}, deleted: $deleted")
listeners.forEach { it.onUpdated(updated, deleted) }
synchronized(isReplacingForBBoxLock) {
@@ -560,3 +568,5 @@ private fun Element.isEqualExceptVersionAndTimestamp(element: Element?): Boolean
is Way -> nodeIds == (element as Way).nodeIds
is Relation -> members == (element as Relation).members
}
+
+private const val TAG = "MapDataWithEditsSource"
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt
index 73a83077a81..feb4f6266f8 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeActionUtils.kt
@@ -3,8 +3,10 @@ package de.westnordost.streetcomplete.data.osm.edits.create
import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource
import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder
+import de.westnordost.streetcomplete.util.math.PositionOnCrossingWaySegments
import de.westnordost.streetcomplete.util.math.PositionOnWay
import de.westnordost.streetcomplete.util.math.PositionOnWaySegment
+import de.westnordost.streetcomplete.util.math.PositionOnWaysSegment
import de.westnordost.streetcomplete.util.math.VertexOfWay
fun createNodeAction(
@@ -30,5 +32,15 @@ fun createNodeAction(
val containingWayIds = mapDataWithEditsSource.getWaysForNode(positionOnWay.nodeId).map { it.id }
return CreateNodeFromVertexAction(node, tagChanges.create(), containingWayIds)
}
+ is PositionOnWaysSegment -> {
+ val tagChanges = StringMapChangesBuilder(mapOf())
+ createChanges(tagChanges)
+ return CreateNodeAction(positionOnWay.position, tagChanges, positionOnWay.insertIntoWaysAt)
+ }
+ is PositionOnCrossingWaySegments -> {
+ val tagChanges = StringMapChangesBuilder(mapOf())
+ createChanges(tagChanges)
+ return CreateNodeAction(positionOnWay.position, tagChanges, positionOnWay.insertIntoWaysAt)
+ }
}
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt
index 040d3aea5fe..b0e214939c5 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateNodeFromVertexAction.kt
@@ -13,7 +13,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.containsExactlyInAnyOrder
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt
new file mode 100644
index 00000000000..7015a2ba28a
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/CreateRelationAction.kt
@@ -0,0 +1,44 @@
+package de.westnordost.streetcomplete.data.osm.edits.create
+
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
+import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
+import de.westnordost.streetcomplete.data.osm.edits.IsActionRevertable
+import de.westnordost.streetcomplete.data.osm.edits.NewElementsCount
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
+import de.westnordost.streetcomplete.data.osm.mapdata.Relation
+import de.westnordost.streetcomplete.data.osm.mapdata.RelationMember
+import de.westnordost.streetcomplete.data.osm.mapdata.key
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CreateRelationAction (
+ val tags: Map,
+ val members: List
+) : ElementEditAction, IsActionRevertable {
+
+ override val newElementsCount get() = NewElementsCount(0, 0, 1)
+
+ override val elementKeys: List get() = members.map { it.key }
+
+ override fun idsUpdatesApplied(updatedIds: Map) = copy(
+ members = members.map { it.copy(ref = updatedIds[it.key] ?: it.ref) }
+ )
+
+ override fun createUpdates(
+ mapDataRepository: MapDataRepository,
+ idProvider: ElementIdProvider
+ ): MapDataChanges {
+ val newRelation = createRelation(idProvider)
+
+ return MapDataChanges(creations = listOf(newRelation))
+ }
+
+ override fun createReverted(idProvider: ElementIdProvider) =
+ RevertCreateRelationAction(createRelation(idProvider))
+
+ private fun createRelation(idProvider: ElementIdProvider) =
+ Relation(idProvider.nextRelationId(), members, tags, 1, nowAsEpochMilliseconds())
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt
index 339a380b591..25ed273db1a 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateNodeAction.kt
@@ -10,7 +10,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
import de.westnordost.streetcomplete.data.osm.mapdata.Way
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt
new file mode 100644
index 00000000000..f624bf2290e
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/create/RevertCreateRelationAction.kt
@@ -0,0 +1,44 @@
+package de.westnordost.streetcomplete.data.osm.edits.create
+
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
+import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
+import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
+import de.westnordost.streetcomplete.data.osm.mapdata.Relation
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RevertCreateRelationAction(
+ val originalRelation: Relation,
+) : ElementEditAction, IsRevertAction {
+
+ override val elementKeys get() = listOf(originalRelation.key)
+
+ override fun idsUpdatesApplied(updatedIds: Map) = copy(
+ originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id))
+
+ override fun createUpdates(
+ mapDataRepository: MapDataRepository,
+ idProvider: ElementIdProvider
+ ): MapDataChanges {
+ val currentRelation = mapDataRepository.getRelation(originalRelation.id)
+ ?: throw ConflictException("Element deleted")
+
+ if (originalRelation.members != currentRelation.members) {
+ throw ConflictException("Relation members changed")
+ }
+
+ if (originalRelation.tags != currentRelation.tags) {
+ throw ConflictException("Some tags have already been changed")
+ }
+
+ if (mapDataRepository.getRelationsForNode(currentRelation.id).isNotEmpty()) {
+ throw ConflictException("Relation is now member of a relation")
+ }
+
+ return MapDataChanges(deletions = listOf(currentRelation))
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt
index 4b22b2e7b99..0ac869a57cf 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeletePoiNodeAction.kt
@@ -9,7 +9,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt
new file mode 100644
index 00000000000..934a031c9bb
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/DeleteRelationAction.kt
@@ -0,0 +1,55 @@
+package de.westnordost.streetcomplete.data.osm.edits.delete
+
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
+import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
+import de.westnordost.streetcomplete.data.osm.edits.IsActionRevertable
+import de.westnordost.streetcomplete.data.osm.edits.update_tags.isGeometrySubstantiallyDifferent
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
+import de.westnordost.streetcomplete.data.osm.mapdata.Relation
+import kotlinx.serialization.Serializable
+
+/** Action that deletes a POI node.
+ *
+ * This is different from a generic element deletion seen in other editors in as ...
+ *
+ * 1. it only works on nodes. This is mainly to reduce complexity, because when deleting ways, it
+ * is expected to implicitly also delete all nodes of that way that are not part of any other
+ * way (or relation).
+ *
+ * 2. if that node is a vertex in a way or has a role in a relation, the node is not deleted but
+ * just "degraded" to be a vertex, i.e. the tags are cleared.
+ * */
+@Serializable
+data class DeleteRelationAction(
+ val originalRelation: Relation
+) : ElementEditAction, IsActionRevertable {
+
+ override val elementKeys get() = listOf(originalRelation.key)
+
+ override fun idsUpdatesApplied(updatedIds: Map) = copy(
+ originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id)
+ )
+
+ override fun createUpdates(
+ mapDataRepository: MapDataRepository,
+ idProvider: ElementIdProvider
+ ): MapDataChanges {
+ val currentRelation = mapDataRepository.getRelation(originalRelation.id)
+ ?: throw ConflictException("Element deleted")
+
+ if (isGeometrySubstantiallyDifferent(originalRelation, currentRelation)) {
+ throw ConflictException("Element geometry changed substantially")
+ }
+
+ // delete relation
+ val relations = mapDataRepository.getRelationsForRelation(currentRelation.id)
+ if (relations.isNotEmpty()) throw ConflictException("Deleting relation that is member of other relation currently not supported")
+ return MapDataChanges(deletions = listOf(currentRelation))
+ }
+
+ override fun createReverted(idProvider: ElementIdProvider) =
+ RevertDeleteRelationAction(originalRelation)
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt
index 1b66b39eb42..66a9a5caa84 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeletePoiNodeAction.kt
@@ -9,7 +9,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt
new file mode 100644
index 00000000000..5db78cdb565
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/delete/RevertDeleteRelationAction.kt
@@ -0,0 +1,49 @@
+package de.westnordost.streetcomplete.data.osm.edits.delete
+
+import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
+import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
+import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction
+import de.westnordost.streetcomplete.data.osm.edits.NewElementsCount
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
+import de.westnordost.streetcomplete.data.osm.mapdata.Relation
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import kotlinx.serialization.Serializable
+
+/** Action that restores a POI node to the previous state before deletion/clearing of tags
+ */
+@Serializable
+data class RevertDeleteRelationAction(
+ val originalRelation: Relation
+) : ElementEditAction, IsRevertAction {
+
+ /** No "new" elements are created, instead, an old one is being revived */
+ override val newElementsCount get() = NewElementsCount(0, 0, 0)
+
+ override val elementKeys get() = listOf(originalRelation.key)
+
+ override fun idsUpdatesApplied(updatedIds: Map) = copy(
+ originalRelation = originalRelation.copy(id = updatedIds[originalRelation.key] ?: originalRelation.id)
+ )
+
+ override fun createUpdates(
+ mapDataRepository: MapDataRepository,
+ idProvider: ElementIdProvider
+ ): MapDataChanges {
+ val newVersion = originalRelation.version + 1
+ val currentRelation = mapDataRepository.getRelation(originalRelation.id)
+
+ // already has been restored apparently
+ if (currentRelation != null && currentRelation.version > newVersion) {
+ throw ConflictException("Element has been restored already")
+ }
+
+ val restoredRelation = originalRelation.copy(
+ version = newVersion,
+ timestampEdited = nowAsEpochMilliseconds()
+ )
+ return MapDataChanges(modifications = listOf(restoredRelation))
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt
index 2fe0363ea00..1db41811eb7 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/MoveNodeAction.kt
@@ -10,7 +10,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt
index d627b99ba04..e43bf5c75ae 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/move/RevertMoveNodeAction.kt
@@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
import de.westnordost.streetcomplete.data.osm.mapdata.Node
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt
index 5e071eb0f86..d0eccebffaf 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/split_way/SplitWayAction.kt
@@ -13,7 +13,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Node
import de.westnordost.streetcomplete.data.osm.mapdata.Relation
import de.westnordost.streetcomplete.data.osm.mapdata.RelationMember
import de.westnordost.streetcomplete.data.osm.mapdata.Way
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.containsAny
import de.westnordost.streetcomplete.util.ktx.findNext
import de.westnordost.streetcomplete.util.ktx.findPrevious
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt
index d8097b9d33c..2936b881dca 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/RevertUpdateElementTagsAction.kt
@@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element
import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.copy
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt
index d1916b3e6f5..ab54099ec2e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/StringMapChangesXt.kt
@@ -29,3 +29,16 @@ fun Element.changesApplied(changes: StringMapChanges): Element {
timestampEdited = nowAsEpochMilliseconds()
)
}
+
+fun Map.createChanges(originalTags: Map): StringMapChangesBuilder {
+ val builder = StringMapChangesBuilder(originalTags)
+ for (key in originalTags.keys) {
+ if (!containsKey(key))
+ builder.remove(key)
+ }
+ for ((key, value) in this) {
+ if (originalTags[key] == value) continue
+ builder[key] = value
+ }
+ return builder
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt
index 2a1c7f4aa54..5b41a574a60 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/update_tags/UpdateElementTagsAction.kt
@@ -8,7 +8,6 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element
import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataRepository
-import de.westnordost.streetcomplete.data.osm.mapdata.key
import de.westnordost.streetcomplete.util.ktx.copy
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
index 5d0644a5386..a6d6c6ab664 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditUploader.kt
@@ -1,17 +1,22 @@
package de.westnordost.streetcomplete.data.osm.edits.upload
+import de.westnordost.streetcomplete.BuildConfig
import de.westnordost.streetcomplete.ApplicationConstants.EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES
import de.westnordost.streetcomplete.ApplicationConstants.IGNORED_RELATION_TYPES
import de.westnordost.streetcomplete.data.ConflictException
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementIdUpdate
import de.westnordost.streetcomplete.data.osm.mapdata.ChangesetTooLargeException
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
+import de.westnordost.streetcomplete.data.osm.mapdata.Way
import de.westnordost.streetcomplete.data.osm.mapdata.RemoteMapDataRepository
+import de.westnordost.streetcomplete.data.user.UserLoginController
+import de.westnordost.streetcomplete.util.ktx.copy
class ElementEditUploader(
private val changesetManager: OpenChangesetsManager,
@@ -27,6 +32,29 @@ class ElementEditUploader(
// certain edit types don't allow building changes on top of cached map data
val mustUseRemoteData = edit.action::class in EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES
+ // fake upload in debug mode: create pseudo-random new (positive!) ids that are unlikely to clash with real ids
+ if (BuildConfig.DEBUG && !UserLoginController.loggedIn) {
+ val localChanges = edit.action.createUpdates(mapDataController, getIdProvider())
+ val creationsByNewId = localChanges.creations.associateBy { Long.MAX_VALUE - Int.MAX_VALUE + it.hashCode() }
+ val updates = MapDataUpdates(
+ updated = (localChanges.modifications + creationsByNewId.map { it.value.copy(id = it.key) })
+ .map { element ->
+ // need to update node ids of ways, don't care about relations
+ if (element is Way && element.nodeIds.any { it < 0 }) {
+ val newNodeIds = element.nodeIds.map { id ->
+ if (id > 0) id
+ else
+ creationsByNewId.entries.first { it.value.id == id }.key
+ }
+ element.copy(nodeIds = newNodeIds)
+ } else element
+ },
+ deleted = localChanges.deletions.map { it.key },
+ idUpdates = creationsByNewId.map { ElementIdUpdate(it.value.type, it.value.id, it.key) }
+ )
+ return updates
+ }
+
return if (mustUseRemoteData) {
uploadUsingRemoteRepo(edit, getIdProvider)
} else {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
index 98335d76ee8..767ea8eb820 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/ElementEditsUploader.kt
@@ -1,10 +1,13 @@
package de.westnordost.streetcomplete.data.osm.edits.upload
+import com.russhwolf.settings.ObservableSettings
import de.westnordost.streetcomplete.data.ConflictException
+import de.westnordost.streetcomplete.BuildConfig
+import de.westnordost.streetcomplete.Prefs
+import de.westnordost.streetcomplete.data.download.Downloader
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
-import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction
import de.westnordost.streetcomplete.data.osm.mapdata.Element
import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
@@ -14,13 +17,21 @@ import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapData
import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestController
+import de.westnordost.streetcomplete.data.externalsource.ExternalSourceQuestType
+import de.westnordost.streetcomplete.data.osm.edits.IsRevertAction
import de.westnordost.streetcomplete.data.upload.OnUploadedChangeListener
+import de.westnordost.streetcomplete.data.upload.Uploader
+import de.westnordost.streetcomplete.data.user.UserLoginController
import de.westnordost.streetcomplete.data.user.statistics.StatisticsController
import de.westnordost.streetcomplete.util.logs.Log
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -31,21 +42,40 @@ class ElementEditsUploader(
private val mapDataController: MapDataController,
private val singleUploader: ElementEditUploader,
private val mapDataApi: MapDataApiClient,
- private val statisticsController: StatisticsController
+ private val statisticsController: StatisticsController,
+ private val downloader: Downloader,
+ private val externalSourceQuestController: ExternalSourceQuestController,
+ private val prefs: ObservableSettings,
) {
var uploadedChangeListener: OnUploadedChangeListener? = null
private val mutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + CoroutineName("ElementEditsUploader"))
- suspend fun upload() = mutex.withLock { withContext(Dispatchers.IO) {
+ suspend fun upload(uploader: Uploader) = mutex.withLock { withContext(Dispatchers.IO) {
while (true) {
val edit = elementEditsController.getOldestUnsynced() ?: break
val getIdProvider: () -> ElementIdProvider = { elementEditsController.getIdProvider(edit.id) }
+ if (downloader.isDownloadInProgress) {
+ // cancel upload, and re-start uploading a second later
+ // then download will already be running
+ scope.launch {
+ delay(1000)
+ try {
+ uploader.upload()
+ } catch (e: Exception) {
+ Log.i(TAG, "exception when continuing interrupted upload: $e")
+ throw CancellationException() // not caught by UploadWorker.doWork, but we don't want
+ }
+ }
+ throw CancellationException() // don't simply break, because otherwise upload will continue with notes
+ }
/* the sync of local change -> API and its response should not be cancellable because
* otherwise an inconsistency in the data would occur. E.g. no "star" for an uploaded
* change, a change could be uploaded twice etc */
withContext(scope.coroutineContext) { uploadEdit(edit, getIdProvider) }
+ if (BuildConfig.DEBUG && !UserLoginController.loggedIn)
+ break // slow uploading is much better to read in logs
}
} }
@@ -53,24 +83,29 @@ class ElementEditsUploader(
val editActionClassName = edit.action::class.simpleName!!
try {
+ if (edit.type is ExternalSourceQuestType && !externalSourceQuestController.onUpload(edit))
+ throw(ConflictException())
val updates = singleUploader.upload(edit, getIdProvider)
- Log.d(TAG, "Uploaded a $editActionClassName")
+ Log.d(TAG, "Uploaded a $editActionClassName for ${edit.action.elementKeys}")
uploadedChangeListener?.onUploaded(edit.type.name, edit.position)
elementEditsController.markSynced(edit, updates)
mapDataController.updateAll(updates)
noteEditsController.updateElementIds(updates.idUpdates)
- if (edit.action is IsRevertAction) {
- statisticsController.subtractOne(edit.type.name, edit.position)
- } else {
- statisticsController.addOne(edit.type.name, edit.position)
+ if (prefs.getBoolean(Prefs.UPDATE_LOCAL_STATISTICS, true)) {
+ if (edit.action is IsRevertAction) {
+ statisticsController.subtractOne(edit.type.name, edit.position)
+ } else {
+ statisticsController.addOne(edit.type.name, edit.position)
+ }
}
} catch (e: ConflictException) {
- Log.d(TAG, "Dropped a $editActionClassName: ${e.message}")
+ Log.d(TAG, "Dropped a $editActionClassName for ${edit.action.elementKeys}: ${e.message}")
uploadedChangeListener?.onDiscarded(edit.type.name, edit.position)
+ externalSourceQuestController.onSyncEditFailed(edit)
elementEditsController.markSyncFailed(edit)
/* fetching the current version of the element(s) edited on conflict and persisting
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
index 1fdd85df85b..92c3bade4d7 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/edits/upload/changesets/OpenChangesetsManager.kt
@@ -65,13 +65,22 @@ class OpenChangesetsManager(
}
private fun createChangesetTags(type: ElementEditType, source: String) =
- mapOf(
- "comment" to type.changesetComment,
- "created_by" to USER_AGENT,
- "locale" to Locale.getDefault().toLanguageTag(),
- QUESTTYPE_TAG_KEY to type.name,
- "source" to source
- )
+ if (source.endsWith(",extra"))
+ mapOf(
+ "comment" to "Other edits in context of: ${type.changesetComment}".take(255),
+ "created_by" to USER_AGENT,
+ "locale" to Locale.getDefault().toLanguageTag(),
+ QUESTTYPE_TAG_KEY to type.name,
+ "source" to source.substringBefore(",extra")
+ )
+ else
+ mapOf(
+ "comment" to type.changesetComment,
+ "created_by" to USER_AGENT,
+ "locale" to Locale.getDefault().toLanguageTag(),
+ QUESTTYPE_TAG_KEY to type.name,
+ "source" to source
+ )
companion object {
private const val TAG = "ChangesetManager"
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
index a6baeb95d56..893ade0b702 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/Element.kt
@@ -10,6 +10,7 @@ sealed class Element {
abstract val tags: Map
abstract val timestampEdited: Long
abstract val type: ElementType
+ abstract val key: ElementKey
}
@Serializable
@@ -23,6 +24,7 @@ data class Node(
) : Element() {
@SerialName("elementType")
override val type get() = ElementType.NODE
+ override val key: ElementKey = ElementKey(ElementType.NODE, id)
}
@Serializable
@@ -36,6 +38,7 @@ data class Way(
) : Element() {
@SerialName("elementType")
override val type = ElementType.WAY
+ override val key: ElementKey = ElementKey(ElementType.WAY, id)
val isClosed get() = nodeIds.size >= 3 && nodeIds.first() == nodeIds.last()
}
@@ -51,6 +54,7 @@ data class Relation(
) : Element() {
@SerialName("elementType")
override val type = ElementType.RELATION
+ override val key: ElementKey = ElementKey(ElementType.RELATION, id)
}
@Serializable
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt
index 308998a9c18..c74f0c739d3 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementDao.kt
@@ -45,7 +45,7 @@ class ElementDao(
fun getAll(bbox: BoundingBox): List {
val nodes = nodeDao.getAll(bbox)
- val nodeIds = nodes.map { it.id }.toSet()
+ val nodeIds = nodes.map { it.id }.toHashSet()
val ways = wayDao.getAllForNodes(nodeIds)
val wayIds = ways.map { it.id }
val additionalWayNodeIds = ways
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt
index d27e81c93d5..c275d236441 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/ElementKey.kt
@@ -8,8 +8,6 @@ data class ElementKey(val type: ElementType, val id: Long) {
override fun toString() = "${type.name}#$id"
}
-val Element.key get() = ElementKey(type, id)
-
val ElementGeometryEntry.key get() = ElementKey(elementType, elementId)
val RelationMember.key get() = ElementKey(type, ref)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt
index e9ac1e52705..2a39a0aa535 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataApiParser.kt
@@ -64,7 +64,9 @@ private fun XmlReader.parseMapData(ignoreRelationTypes: Set): MutableMa
END_ELEMENT -> when (localName) {
"node" -> result.add(Node(id!!, position!!, tags.orEmpty(), version!!, timestamp!!))
"way" -> result.add(Way(id!!, nodes, tags.orEmpty(), version!!, timestamp!!))
- "relation" -> if (tags.orEmpty()["type"] !in ignoreRelationTypes) {
+ "relation" -> if (tags.orEmpty()["type"] !in ignoreRelationTypes
+ || (members.size <= 100 && tags.orEmpty()["route"] in allowRouteTypes)
+ ) {
result.add(Relation(id!!, members, tags.orEmpty(), version!!, timestamp!!))
}
}
@@ -96,3 +98,5 @@ private fun XmlReader.parseElementUpdates(): Map Pair, Collection>, // used if the tile is not contained
private val fetchNodes: (Collection) -> Collection,
@@ -30,19 +32,16 @@ class MapDataCache(
maxTiles,
initialCapacity,
{ emptyList() }, // data is fetched using fetchMapData and put using spatialCache.replaceAllInBBox
- Node::id, Node::position
+ Node::key, Node::position,
)
// initial values obtained from a spot check:
// approximately 80% of all elements were found to be nodes
// approximately every second node is part of a way
// more than 90% of elements are not part of a relation
- private val nodesOutsideSpatialCache = HashMap(initialCapacity)
- private val wayCache = HashMap(initialCapacity / 6)
- private val relationCache = HashMap(initialCapacity / 10)
- private val wayGeometryCache = HashMap(initialCapacity / 6)
- private val relationGeometryCache = HashMap(initialCapacity / 10)
- private val wayIdsByNodeIdCache = HashMap>(initialCapacity / 2)
- private val relationIdsByElementKeyCache = HashMap>(initialCapacity / 10)
+ private val notSpatialCache = HashMap(initialCapacity / 4)
+ private val wayRelationGeometryCache = HashMap(initialCapacity / 5)
+ private val wayKeyByNodeKeyCache = HashMap>(initialCapacity / 2)
+ private val relationKeysByElementKeyCache = HashMap>(initialCapacity / 10)
/**
* Removes elements and geometries with keys in [deletedKeys] from cache and puts the
@@ -57,65 +56,74 @@ class MapDataCache(
bbox: BoundingBox? = null
) { synchronized(this) {
val updatedNodes = updatedElements.filterIsInstance()
- val deletedNodeIds = deletedKeys.mapNotNull { if (it.type == ElementType.NODE) it.id else null }
+ val deletedNodeKeys = deletedKeys.filter { it.type == ElementType.NODE }
if (bbox == null) {
// just update nodes if the containing tile
- spatialCache.update(updatedOrAdded = updatedNodes, deleted = deletedNodeIds)
+ spatialCache.update(updatedOrAdded = updatedNodes, deleted = deletedNodeKeys)
} else {
// delete first, then put bbox and nodes to spatialCache (adds/clears tiles in bbox)
- spatialCache.update(deleted = deletedNodeIds)
+ spatialCache.update(deleted = deletedNodeKeys)
spatialCache.replaceAllInBBox(updatedNodes, bbox)
}
- // remove all cached nodes that are now in spatialCache
- nodesOutsideSpatialCache.keys.removeAll { spatialCache.get(it) != null }
// delete nodes, ways and relations
for (key in deletedKeys) {
when (key.type) {
ElementType.NODE -> {
- wayIdsByNodeIdCache.remove(key.id)
- nodesOutsideSpatialCache.remove(key.id)
+ wayKeyByNodeKeyCache.remove(key)
+ notSpatialCache.remove(key)
}
ElementType.WAY -> {
- val deletedWayNodeIds = wayCache.remove(key.id)?.nodeIds.orEmpty()
+ val deletedWayNodeIds = (notSpatialCache.remove(key) as? Way)?.nodeIds.orEmpty()
for (nodeId in deletedWayNodeIds) {
- wayIdsByNodeIdCache[nodeId]?.remove(key.id)
+ wayKeyByNodeKeyCache[ElementKey(ElementType.NODE, nodeId)]?.remove(key)
}
- wayGeometryCache.remove(key.id)
+ wayRelationGeometryCache.remove(key)
}
ElementType.RELATION -> {
- val deletedRelationMembers = relationCache.remove(key.id)?.members.orEmpty()
+ val deletedRelationMembers = (notSpatialCache.remove(key) as? Relation)?.members.orEmpty()
for (member in deletedRelationMembers) {
- relationIdsByElementKeyCache[member.key]?.remove(key.id)
+ relationKeysByElementKeyCache[member.key]?.remove(key)
}
- relationGeometryCache.remove(key.id)
+ wayRelationGeometryCache.remove(key)
}
}
}
// update way and relation geometries
for (entry in updatedGeometries) {
- if (entry.elementType == ElementType.WAY) {
- wayGeometryCache[entry.elementId] = entry.geometry
- } else if (entry.elementType == ElementType.RELATION) {
- relationGeometryCache[entry.elementId] = entry.geometry
- }
+ if (entry.elementType != ElementType.NODE)
+ wayRelationGeometryCache[entry.reuseKey] = entry.geometry
}
- // add nodes that are not in spatialCache to nodeCache
- updatedNodes.forEach { if (spatialCache.get(it.id) == null) nodesOutsideSpatialCache[it.id] = it }
+ if (bbox == null)
+ updatedNodes.forEach {
+ // updated nodes are either in spatialCache, then remove from notSpatialCache
+ // or the are not, then add to notSpatialCache
+ if (spatialCache.get(it.key) == null) notSpatialCache[it.key] = it
+ else notSpatialCache.remove(it.key)
+ }
+ else {
+ // spatialCache may have changed size, better remove all nodes that are not in spatialCache
+
+ // remove all cached nodes that are now in spatialCache
+ notSpatialCache.keys.removeAll { it.type == ElementType.NODE && spatialCache.get(it) != null }
+
+ // add nodes that are not in spatialCache to nodeCache
+ updatedNodes.forEach { if (spatialCache.get(it.key) == null) notSpatialCache[it.key] = it }
+ }
// update ways
val updatedWays = updatedElements.filterIsInstance()
for (way in updatedWays) {
// updated way may have different node ids than old one, so those need to be removed first
- val oldWay = wayCache[way.id]
+ val oldWay = notSpatialCache[way.key] as? Way
if (oldWay != null) {
for (oldNodeId in oldWay.nodeIds) {
- wayIdsByNodeIdCache[oldNodeId]?.remove(way.id)
+ wayKeyByNodeKeyCache[ElementKey(ElementType.NODE, oldNodeId)]?.remove(way.key)
}
}
- wayCache[way.id] = way
+ notSpatialCache[way.key] = way
// ...and then the new node ids added
for (nodeId in way.nodeIds) {
// only if the node is already in spatial cache, the way ids it refers to must be known:
@@ -126,12 +134,14 @@ class MapDataCache(
// or if an entry for that node already exists (cached from getWaysForNode).
// Otherwise the cache may return an incomplete list of ways in getWaysForNode,
// instead of fetching the correct list.
- val wayIdsReferredByNode = if (spatialCache.get(nodeId) != null) {
- wayIdsByNodeIdCache.getOrPut(nodeId) { ArrayList(2) }
+ val nodeKey = ElementKey(ElementType.NODE, nodeId)
+ val node = spatialCache.get(nodeKey)
+ val wayIdsReferredByNode = if (node != null) {
+ wayKeyByNodeKeyCache.getOrPut(node.key) { ArrayList(2) }
} else {
- wayIdsByNodeIdCache[nodeId]
+ wayKeyByNodeKeyCache[nodeKey]
}
- wayIdsReferredByNode?.add(way.id)
+ wayIdsReferredByNode?.add(way.key)
}
}
@@ -142,35 +152,46 @@ class MapDataCache(
// in spatialCache, or have a node / member in spatialCache (same reasoning as for ways)
val (wayIds, relationIds) = determineWayAndRelationIdsWithElementsInSpatialCache()
+ lateinit var memberKey: ElementKey
for (relation in updatedRelations) {
// old relation may now have different members, so they need to be removed first
- val oldRelation = relationCache[relation.id]
+ val oldRelation = notSpatialCache[relation.key] as? Relation
if (oldRelation != null) {
for (oldMember in oldRelation.members) {
- relationIdsByElementKeyCache[oldMember.key]?.remove(relation.id)
+ relationKeysByElementKeyCache[oldMember.key]?.remove(relation.key)
}
}
- relationCache[relation.id] = relation
-
+ notSpatialCache[relation.key] = relation
// ...and then the new members added
for (member in relation.members) {
- val memberKey = member.key
+ memberKey = member.key
// only if the node member is already in the spatial cache or any node of a member
// is, the relation ids it refers to must be known:
// relationIdsByElementKeyCache is required for getMapDataWithGeometry(bbox),
// because a relation is inside the bbox if it contains a member inside the bbox,
// see comment above for wayIdsReferredByNode
val isInSpatialCache = when (member.type) {
- ElementType.NODE -> spatialCache.get(member.ref) != null
- ElementType.WAY -> member.ref in wayIds
+ ElementType.NODE -> {
+ val node = spatialCache.get(memberKey)
+ if (node != null) {
+ memberKey = node.key
+ true
+ } else false
+ }
+ ElementType.WAY -> {
+ if (member.ref in wayIds) {
+ notSpatialCache[memberKey]?.let { memberKey = it.key }
+ true
+ } else false
+ }
ElementType.RELATION -> member.ref in relationIds
}
val relationIdsReferredByMember = if (isInSpatialCache) {
- relationIdsByElementKeyCache.getOrPut(memberKey) { ArrayList(2) }
+ relationKeysByElementKeyCache.getOrPut(memberKey) { ArrayList(2) }
} else {
- relationIdsByElementKeyCache[memberKey]
+ relationKeysByElementKeyCache[memberKey]
}
- relationIdsReferredByMember?.add(relation.id)
+ relationIdsReferredByMember?.add(relation.key)
}
}
}
@@ -185,10 +206,10 @@ class MapDataCache(
id: Long,
fetch: (ElementType, Long) -> Element?
): Element? = synchronized(this) {
- when (type) {
- ElementType.NODE -> spatialCache.get(id) ?: nodesOutsideSpatialCache.getOrPutIfNotNull(id) { fetch(type, id) as? Node }
- ElementType.WAY -> wayCache.getOrPutIfNotNull(id) { fetch(type, id) as? Way }
- ElementType.RELATION -> relationCache.getOrPutIfNotNull(id) { fetch(type, id) as? Relation }
+ val key = ElementKey(type, id)
+ when (key.type) {
+ ElementType.NODE -> spatialCache.get(key) ?: notSpatialCache.getOrPutIfNotNull(key) { fetch(key.type, key.id) }
+ else -> notSpatialCache.getOrPutIfNotNull(key) { fetch(key.type, key.id) }
}
}
@@ -199,12 +220,13 @@ class MapDataCache(
fun getGeometry(
type: ElementType,
id: Long,
- fetch: (ElementType, Long) -> ElementGeometry?
+ fetch: (ElementType, Long) -> ElementGeometry?,
+ fetchNode: (Long) -> Node?
): ElementGeometry? = synchronized(this) {
- return when (type) {
- ElementType.NODE -> getCachedNode(id)?.let { ElementPointGeometry(it.position) } ?: fetch(type, id)
- ElementType.WAY -> wayGeometryCache.getOrPutIfNotNull(id) { fetch(type, id) }
- ElementType.RELATION -> relationGeometryCache.getOrPutIfNotNull(id) { fetch(type, id) }
+ val key = ElementKey(type, id)
+ when (type) {
+ ElementType.NODE -> (spatialCache.get(key) ?: notSpatialCache.getOrPutIfNotNull(key) { fetchNode(id) })?.let { ElementPointGeometry((it as Node).position) }
+ else -> wayRelationGeometryCache.getOrPutIfNotNull(key) { fetch(type, id) }
}
}
@@ -220,9 +242,8 @@ class MapDataCache(
): List = synchronized(this) {
val cachedElements = keys.mapNotNull { key ->
when (key.type) {
- ElementType.NODE -> getCachedNode(key.id)
- ElementType.WAY -> wayCache[key.id]
- ElementType.RELATION -> relationCache[key.id]
+ ElementType.NODE -> getCachedNode(key)
+ else -> notSpatialCache[key]
}
}
@@ -230,15 +251,11 @@ class MapDataCache(
if (keys.size == cachedElements.size) return cachedElements
// otherwise, fetch the rest & save to cache
- val cachedKeys = cachedElements.map { it.key }.toSet()
+ val cachedKeys = cachedElements.mapTo(HashSet(cachedElements.size)) { it.key }
val keysToFetch = keys.filterNot { it in cachedKeys }
val fetchedElements = fetch(keysToFetch)
for (element in fetchedElements) {
- when (element.type) {
- ElementType.NODE -> nodesOutsideSpatialCache[element.id] = element as Node
- ElementType.WAY -> wayCache[element.id] = element as Way
- ElementType.RELATION -> relationCache[element.id] = element as Relation
- }
+ notSpatialCache[element.key] = element
}
return cachedElements + fetchedElements
}
@@ -246,14 +263,14 @@ class MapDataCache(
/** Gets the nodes with the given [ids] from cache. If any of the nodes are not cached, [fetch]
* is called for the missing nodes. */
fun getNodes(ids: Collection, fetch: (Collection) -> List): List = synchronized(this) {
- val cachedNodes = ids.mapNotNull { getCachedNode(it) }
+ val cachedNodes = ids.mapNotNull { getCachedNode(ElementKey(ElementType.NODE, it)) }
if (ids.size == cachedNodes.size) return cachedNodes
// not all in cache: must fetch the rest from db
- val cachedNodeIds = cachedNodes.map { it.id }.toSet()
+ val cachedNodeIds = cachedNodes.mapTo(HashSet(cachedNodes.size)) { it.id }
val missingNodeIds = ids.filterNot { it in cachedNodeIds }
val fetchedNodes = fetch(missingNodeIds)
- fetchedNodes.forEach { nodesOutsideSpatialCache[it.id] = it }
+ fetchedNodes.forEach { notSpatialCache[it.key] = it }
return cachedNodes + fetchedNodes
}
@@ -281,15 +298,15 @@ class MapDataCache(
*/
fun getGeometries(
keys: Collection,
- fetch: (Collection) -> List
+ fetch: (Collection) -> List,
+ fetchNodes: (Collection) -> List
): List = synchronized(this) {
// the implementation here is quite identical to the implementation in getElements, only
// that geometries and not elements are returned and thus different caches are accessed
val cachedEntries = keys.mapNotNull { key ->
when (key.type) {
- ElementType.NODE -> getCachedNode(key.id)?.let { ElementPointGeometry(it.position) }
- ElementType.WAY -> wayGeometryCache[key.id]
- ElementType.RELATION -> relationGeometryCache[key.id]
+ ElementType.NODE -> getCachedNode(key)?.let { ElementPointGeometry(it.position) }
+ else -> wayRelationGeometryCache[key]
}?.let { ElementGeometryEntry(key.type, key.id, it) }
}
@@ -297,17 +314,16 @@ class MapDataCache(
if (keys.size == cachedEntries.size) return cachedEntries
// otherwise, fetch the rest & save to cache
- val cachedKeys = cachedEntries.map { it.key }.toSet()
+ val cachedKeys = cachedEntries.mapTo(HashSet(cachedEntries.size)) { it.key }
val keysToFetch = keys.filterNot { it in cachedKeys }
- val fetchedEntries = fetch(keysToFetch)
+ val fetchedEntries = fetch(keysToFetch.filterNot { it.type == ElementType.NODE }) // only fetch non-nodes
for (entry in fetchedEntries) {
- when (entry.elementType) {
- ElementType.WAY -> wayGeometryCache[entry.elementId] = entry.geometry
- ElementType.RELATION -> relationGeometryCache[entry.elementId] = entry.geometry
- else -> Unit
- }
+ wayRelationGeometryCache[entry.key] = entry.geometry // no nodes fetched anyway
}
- return cachedEntries + fetchedEntries
+ // now fetch the nodes separately and add them to nodeCache
+ val nodes = fetchNodes(keysToFetch.mapNotNull { if (it.type == ElementType.NODE) it.id else null })
+ nodes.forEach { notSpatialCache[it.key] = it }
+ return cachedEntries + fetchedEntries + nodes.map { it.toElementGeometryEntry() }
}
/**
@@ -315,12 +331,12 @@ class MapDataCache(
* or any way is missing in cache, [fetch] is called and the result cached.
*/
fun getWaysForNode(id: Long, fetch: (Long) -> List): List = synchronized(this) {
- val wayIds = wayIdsByNodeIdCache.getOrPut(id) {
+ val wayIds = wayKeyByNodeKeyCache.getOrPut(ElementKey(ElementType.NODE, id)) {
val ways = fetch(id)
- for (way in ways) { wayCache[way.id] = way }
- ways.map { it.id }.toMutableList()
+ for (way in ways) { notSpatialCache[way.key] = way }
+ ways.map { it.key }.toMutableList()
}
- return wayIds.mapNotNull { wayCache[it] }
+ return wayIds.mapNotNull { notSpatialCache[it] as? Way }
}
/**
@@ -328,33 +344,32 @@ class MapDataCache(
* not known, or any relation is missing in cache, [fetch] is called and the result cached.
*/
fun getRelationsForNode(id: Long, fetch: (Long) -> List) =
- getRelationsForElement(ElementType.NODE, id) { fetch(id) }
+ getRelationsForElement(ElementKey(ElementType.NODE, id)) { fetch(id) }
/**
* Gets all relations for way with the given [id] from cache. If the list of relations is not
* known, or any relation is missing in cache, [fetch] is called and the result cached.
*/
fun getRelationsForWay(id: Long, fetch: (Long) -> List) =
- getRelationsForElement(ElementType.WAY, id) { fetch(id) }
+ getRelationsForElement(ElementKey(ElementType.WAY, id)) { fetch(id) }
/**
* Gets all relations for way with the given [id] from cache. If the list of relations is not
* known, or any relation is missing in cache, [fetch] is called and the result cached.
*/
fun getRelationsForRelation(id: Long, fetch: (Long) -> List) =
- getRelationsForElement(ElementType.RELATION, id) { fetch(id) }
+ getRelationsForElement(ElementKey(ElementType.RELATION, id)) { fetch(id) }
private fun getRelationsForElement(
- type: ElementType,
- id: Long,
+ key: ElementKey,
fetch: () -> List
): List = synchronized(this) {
- val relationIds = relationIdsByElementKeyCache.getOrPut(ElementKey(type, id)) {
+ val relationIds = relationKeysByElementKeyCache.getOrPut(key) {
val relations = fetch()
- for (relation in relations) { relationCache[relation.id] = relation }
- relations.map { it.id }.toMutableList()
+ for (relation in relations) { notSpatialCache[relation.key] = relation }
+ relations.map { it.key }.toMutableList()
}
- return relationIds.mapNotNull { relationCache[it] }
+ return relationIds.mapNotNull { notSpatialCache[it] as? Relation }
}
/**
@@ -366,17 +381,11 @@ class MapDataCache(
fun getMapDataWithGeometry(bbox: BoundingBox): MutableMapDataWithGeometry = synchronized(this) {
val requiredTiles = bbox.enclosingTilesRect(tileZoom).asTilePosSequence().toList()
val cachedTiles = spatialCache.getTiles()
- val tilesToFetch = requiredTiles.filterNot { it in cachedTiles }
- val tilesRectToFetch = tilesToFetch.minTileRect()
-
- val result = MutableMapDataWithGeometry()
- result.boundingBox = bbox
- val nodes: Collection
- if (tilesRectToFetch != null) {
- // fetch needed data
- val fetchBBox = tilesRectToFetch.asBoundingBox(tileZoom)
- val (elements, geometries) = fetchMapData(fetchBBox)
+ val tilesRectsToFetch = requiredTiles.filterNot { it in cachedTiles }.upToTwoMinTileRects()
+ val result: MutableMapDataWithGeometry
+ if (tilesRectsToFetch != null) {
+ Log.i(TAG, "need to fetch data in $tilesRectsToFetch from database")
// get nodes from spatial cache
// this may not contain all nodes, but tiles that were cached initially might
// get dropped when the caches are updated
@@ -384,47 +393,57 @@ class MapDataCache(
// get(bbox) for tiles not in spatialCache calls spatialCache.fetch, but this is still
// safe as tiles are replaced and properly filled as part of the following update
- nodes = HashSet(spatialCache.get(bbox))
- update(updatedElements = elements, updatedGeometries = geometries, bbox = fetchBBox)
-
- // return data if we need exactly what was just fetched
- if (fetchBBox == bbox) {
- val nodeGeometryEntries = elements.filterIsInstance().map { it.toElementGeometryEntry() }
- result.putAll(elements, geometries + nodeGeometryEntries)
- return result
+ spatialCache.get(bbox).also {
+ result = MutableMapDataWithGeometry(min(it.size * 2, 1000))
+ it.forEach { result.put(it, ElementPointGeometry(it.position)) }
}
- // add newly fetched nodes from elements
- // getting nodes from spatialCache can cause issues, as tiles in the bbox may now be removed unexpectedly
- // see https://github.com/streetcomplete/StreetComplete/issues/4980#issuecomment-1531960544
- for (element in elements) {
- if (element !is Node) continue
- if (element.position in bbox) nodes.add(element)
+ // fetch needed data and put it to cache
+ tilesRectsToFetch.forEach { tilesRect ->
+ val fetchBBox = tilesRect.asBoundingBox(tileZoom)
+ val (elements, geometries) = fetchMapData(fetchBBox)
+ update(updatedElements = elements, updatedGeometries = geometries, bbox = fetchBBox)
+ if (fetchBBox == bbox) {
+ // return data if we need exactly the bbox that was just fetched
+ result.putAll(elements, geometries + elements.filterIsInstance().map { it.toElementGeometryEntry() })
+ result.boundingBox = bbox
+ return result
+ }
+ // add newly fetched nodes from elements
+ // getting nodes from spatialCache can cause issues, as tiles in the bbox may now be removed unexpectedly
+ // see https://github.com/streetcomplete/StreetComplete/issues/4980#issuecomment-1531960544
+ for (element in elements) {
+ if (element !is Node) continue
+ if (element.position in bbox) result.put(element, ElementPointGeometry(element.position))
+ }
}
} else {
- nodes = spatialCache.get(bbox)
+ spatialCache.get(bbox).also {
+ result = MutableMapDataWithGeometry(it.size)
+ it.forEach { result.put(it, ElementPointGeometry(it.position)) }
+ }
}
+ result.boundingBox = bbox
- val wayIds = HashSet(nodes.size / 5)
- val relationIds = HashSet(nodes.size / 10)
- for (node in nodes) {
- wayIdsByNodeIdCache[node.id]?.let { wayIds.addAll(it) }
- relationIdsByElementKeyCache[node.key]?.let { relationIds.addAll(it) }
- result.put(node, ElementPointGeometry(node.position))
+ val wayKeys = HashSet(result.nodes.size / 5)
+ val relationKeys = HashSet(result.nodes.size / 10)
+ for (node in result.nodes) {
+ wayKeyByNodeKeyCache[node.key]?.let { wayKeys.addAll(it) }
+ relationKeysByElementKeyCache[node.key]?.let { relationKeys.addAll(it) }
}
val nodesToFetch = hashSetOf()
- for (wayId in wayIds) {
- val way = wayCache[wayId]!!
- val wayGeometry = wayGeometryCache[wayId]
+ for (wayKey in wayKeys) {
+ val way = notSpatialCache[wayKey] as Way
+ val wayGeometry = wayRelationGeometryCache[wayKey]
result.put(way, wayGeometry)
- relationIdsByElementKeyCache[way.key]?.let { relationIds.addAll(it) }
+ relationKeysByElementKeyCache[way.key]?.let { relationKeys.addAll(it) }
// find all nodes that are part of the way, but not in result
if (wayGeometry?.getBounds()?.isCompletelyInside(bbox) == true) continue // no need to check
for (nodeId in way.nodeIds) {
if (result.getNode(nodeId) != null) continue
- val cachedNode = getCachedNode(nodeId)
+ val cachedNode = getCachedNode(ElementKey(ElementType.NODE, nodeId))
if (cachedNode != null) {
result.put(cachedNode, ElementPointGeometry(cachedNode.position))
continue
@@ -434,19 +453,19 @@ class MapDataCache(
}
if (nodesToFetch.isNotEmpty()) {
fetchNodes(nodesToFetch).forEach {
- nodesOutsideSpatialCache[it.id] = it
+ notSpatialCache[it.key] = it
result.put(it, ElementPointGeometry(it.position))
}
}
- for (relationId in relationIds) {
- result.put(relationCache[relationId]!!, relationGeometryCache[relationId])
+ for (relationKey in relationKeys) {
+ result.put(notSpatialCache[relationKey]!!, wayRelationGeometryCache[relationKey])
// don't add relations of relations, because elementDao.getAll(bbox) also isn't doing that
}
// trim if we fetched new data, and spatialCache is full
// trim to 66%, so trim is (probably) not immediately called on next fetch
- if (spatialCache.size >= maxTiles && tilesToFetch.isNotEmpty()) {
+ if (tilesRectsToFetch != null && spatialCache.size >= maxTiles) {
trim((maxTiles * 2) / 3)
}
return result
@@ -454,36 +473,43 @@ class MapDataCache(
/** Clears the cache */
fun clear() { synchronized(this) {
+ Log.i(TAG, "clear cache")
spatialCache.clear()
- nodesOutsideSpatialCache.clear()
- wayCache.clear()
- relationCache.clear()
- wayGeometryCache.clear()
- relationGeometryCache.clear()
- wayIdsByNodeIdCache.clear()
- relationIdsByElementKeyCache.clear()
+ notSpatialCache.clear()
+ wayRelationGeometryCache.clear()
+ wayKeyByNodeKeyCache.clear()
+ relationKeysByElementKeyCache.clear()
} }
/** Reduces cache size to the given number of non-empty [tiles], and removes all data
* not contained in the remaining tiles.
*/
fun trim(tiles: Int) { synchronized(this) {
- spatialCache.trim(tiles)
- nodesOutsideSpatialCache.clear() // simply clear nodeCache, as transferring some nodes from spatialCache is slow
+ Log.i(TAG, "trim to $tiles tiles")
// ways and relations with at least one element in cache should not be removed
val (wayIds, relationIds) = determineWayAndRelationIdsWithElementsInSpatialCache()
- wayCache.keys.retainAll { it in wayIds }
- relationCache.keys.retainAll { it in relationIds }
- wayGeometryCache.keys.retainAll { it in wayIds }
- relationGeometryCache.keys.retainAll { it in relationIds }
+ notSpatialCache.keys.retainAll {
+ when (it.type) {
+ ElementType.WAY -> it.id in wayIds
+ ElementType.RELATION -> it.id in relationIds
+ else -> false
+ }
+ }
+ wayRelationGeometryCache.keys.retainAll {
+ when (it.type) {
+ ElementType.WAY -> it.id in wayIds
+ ElementType.RELATION -> it.id in relationIds
+ else -> false
+ }
+ }
// now clean up wayIdsByNodeIdCache and relationIdsByElementKeyCache
- wayIdsByNodeIdCache.keys.retainAll { spatialCache.get(it) != null }
- relationIdsByElementKeyCache.keys.retainAll {
+ wayKeyByNodeKeyCache.keys.retainAll { spatialCache.get(it) != null }
+ relationKeysByElementKeyCache.keys.retainAll {
when (it.type) {
- ElementType.NODE -> spatialCache.get(it.id) != null
+ ElementType.NODE -> spatialCache.get(ElementKey(ElementType.NODE, it.id)) != null
ElementType.WAY -> it.id in wayIds
ElementType.RELATION -> it.id in relationIds
}
@@ -498,23 +524,25 @@ class MapDataCache(
// and now the other caches are outdated. So this method exists to find those elements that
// are STILL referred to directly or indirectly by the spatial cache.
- val wayIds = HashSet(wayCache.size)
- for (way in wayCache.values) {
- if (way.nodeIds.any { spatialCache.get(it) != null }) {
+ val wayIds = HashSet(notSpatialCache.size)
+ for (way in notSpatialCache.values) {
+ if (way !is Way) continue
+ if (way.nodeIds.any { spatialCache.get(ElementKey(ElementType.NODE, it)) != null }) {
wayIds.add(way.id)
}
}
fun RelationMember.isCachedWayOrNode(): Boolean =
- type == ElementType.NODE && spatialCache.get(ref) != null
+ type == ElementType.NODE && spatialCache.get(key) != null
|| type == ElementType.WAY && ref in wayIds
fun RelationMember.hasCachedMembers(): Boolean =
type == ElementType.RELATION
- && relationCache[ref]?.members?.any { it.isCachedWayOrNode() } == true
+ && (notSpatialCache[key] as? Relation)?.members?.any { it.isCachedWayOrNode() } == true
- val relationIds = HashSet(relationCache.size)
- for (relation in relationCache.values) {
+ val relationIds = HashSet(notSpatialCache.size / 3)
+ for (relation in notSpatialCache.values) {
+ if (relation !is Relation) continue
if (relation.members.any { it.isCachedWayOrNode() || it.hasCachedMembers() }) {
relationIds.add(relation.id)
}
@@ -534,5 +562,11 @@ class MapDataCache(
private fun Node.toElementGeometryEntry() =
ElementGeometryEntry(type, id, ElementPointGeometry(position))
- private fun getCachedNode(id: Long): Node? = spatialCache.get(id) ?: nodesOutsideSpatialCache[id]
+ private fun getCachedNode(key: ElementKey): Node? = spatialCache.get(key) ?: (notSpatialCache[key] as? Node)
+
+ private val ElementGeometryEntry.reuseKey get() = key.let { notSpatialCache[it]?.key ?: it }
+
+ companion object {
+ private const val TAG = "MapDataCache"
+ }
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt
index ff3f25f556e..85a50a9a135 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataController.kt
@@ -18,7 +18,7 @@ class MapDataController internal constructor(
private val elementDB: ElementDao,
private val geometryDB: ElementGeometryDao,
private val elementGeometryCreator: ElementGeometryCreator,
- private val createdElementsController: CreatedElementsController
+ private val createdElementsController: CreatedElementsController,
) : MapDataRepository {
/* Must be a singleton because there is a listener that should respond to a change in the
@@ -67,7 +67,7 @@ class MapDataController internal constructor(
geometryEntries = createGeometries(mapData, mapData)
// don't use cache here, because if not everything is already cached, db call will be faster
- oldElementKeys = elementDB.getAllKeys(mapData.boundingBox!!).toMutableSet()
+ oldElementKeys = elementDB.getAllKeys(mapData.boundingBox!!).toHashSet()
for (element in mapData) {
oldElementKeys.remove(element.key)
}
@@ -84,7 +84,7 @@ class MapDataController internal constructor(
Log.i(TAG,
"Persisted ${geometryEntries.size} and deleted ${oldElementKeys.size} elements and geometries" +
- " in ${((nowAsEpochMilliseconds() - time) / 1000.0).format(1)}s"
+ " in ${((nowAsEpochMilliseconds() - time) / 1000.0).format(1)}s"
)
val mapDataWithGeometry = MutableMapDataWithGeometry(mapData, geometryEntries)
@@ -163,10 +163,10 @@ class MapDataController internal constructor(
}
fun getGeometry(type: ElementType, id: Long): ElementGeometry? =
- cache.getGeometry(type, id, geometryDB::get)
+ cache.getGeometry(type, id, geometryDB::get, nodeDB::get)
fun getGeometries(keys: Collection): List =
- cache.getGeometries(keys, geometryDB::getAllEntries)
+ cache.getGeometries(keys, geometryDB::getAllEntries, nodeDB::getAll)
fun getMapDataWithGeometry(bbox: BoundingBox): MutableMapDataWithGeometry =
cache.getMapDataWithGeometry(bbox)
@@ -200,7 +200,7 @@ class MapDataController internal constructor(
override fun getWayComplete(id: Long): MapData? {
val way = getWay(id) ?: return null
- val nodeIds = way.nodeIds.toSet()
+ val nodeIds = way.nodeIds.toHashSet()
val nodes = getNodes(nodeIds)
if (nodes.size < nodeIds.size) return null
return MutableMapData(nodes + way)
@@ -208,7 +208,7 @@ class MapDataController internal constructor(
override fun getRelationComplete(id: Long): MapData? {
val relation = getRelation(id) ?: return null
- val elementKeys = relation.members.map { it.key }.toSet()
+ val elementKeys = relation.members.mapTo(HashSet(relation.members.size)) { it.key }
val elements = getAll(elementKeys)
if (elements.size < elementKeys.size) return null
return MutableMapData(elements + relation)
@@ -256,7 +256,7 @@ class MapDataController internal constructor(
fun clearCache() = synchronized(this) { cache.clear() }
- fun trimCache() = synchronized(this) { cache.trim(SPATIAL_CACHE_TILES / 3) }
+ fun trimCache() = synchronized(this) { cache.trim(SPATIAL_CACHE_TILES / 4) }
fun addListener(listener: Listener) {
listeners.add(listener)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt
index 4e33990877a..9a4a6dbfbdb 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MapDataUpdates.kt
@@ -24,7 +24,9 @@ fun createMapDataUpdates(
val idUpdates = mutableListOf()
for (element in elements) {
- if (element is Relation && element.tags["type"] in ignoreRelationTypes) continue
+ if (element is Relation
+ && (element.tags["type"] in ignoreRelationTypes && (element.members.size > 100 || element.tags["route"] !in allowRouteTypes))
+ ) continue
val newElement = element.update(updates)
if (newElement == null) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt
index 0bed4805de4..36fc2902897 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/MutableMapDataWithGeometry.kt
@@ -4,9 +4,9 @@ import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry
import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry
-class MutableMapDataWithGeometry() : MapDataWithGeometry {
+class MutableMapDataWithGeometry(capacity: Int = 50) : MapDataWithGeometry {
- constructor(elements: Iterable, geometryEntries: Iterable) : this() {
+ constructor(elements: Iterable, geometryEntries: Iterable) : this((elements as? Collection)?.size ?: 50) {
putAll(elements, geometryEntries)
}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt
index b910ada40ba..a5be779a02e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/osm/mapdata/NodeDao.kt
@@ -1,5 +1,8 @@
package de.westnordost.streetcomplete.data.osm.mapdata
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
import de.westnordost.streetcomplete.data.CursorPosition
import de.westnordost.streetcomplete.data.Database
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry
@@ -13,9 +16,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.Columns.TIMESTAM
import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.Columns.VERSION
import de.westnordost.streetcomplete.data.osm.mapdata.NodeTable.NAME
import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
+import de.westnordost.streetcomplete.util.ktx.toInternedMap
/** Stores OSM nodes */
class NodeDao(private val db: Database) {
@@ -42,7 +43,7 @@ class NodeDao(private val db: Database) {
node.version,
node.position.latitude,
node.position.longitude,
- if (node.tags.isNotEmpty()) Json.encodeToString(node.tags) else null,
+ if (node.tags.isNotEmpty()) jsonAdapter.toJson(node.tags) else null,
node.timestampEdited,
time
)
@@ -88,13 +89,18 @@ class NodeDao(private val db: Database) {
}
fun getAll(bbox: BoundingBox): List =
- db.query(NAME, where = inBoundsSql(bbox)) { it.toNode() }
+ db.query(NAME, columns = arrayOf(ID, LATITUDE, LONGITUDE, TAGS, VERSION, TIMESTAMP), where = inBoundsSql(bbox)) { it.toNode() }
}
+private val jsonAdapter: JsonAdapter