diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e2a20066bdb..5a5b93bdc91 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -138,6 +138,8 @@ Materials in [`res/documentation`](res/documentation) also may be useful, it inc
* [streetcomplete-ad-c3](https://github.com/rugk/streetcomplete-ad-c3) by [@rugk](https://github.com/rugk) as a banner advertisement
* [sc-photo-service](https://github.com/streetcomplete/sc-photo-service) by [@exploide](https://github.com/exploide) allows StreetComplete to upload photos associated with OSM Notes
* [sc-statistics-service](https://github.com/streetcomplete/sc-statistics-service) by [@westnordost](https://github.com/westnordost) aggregates and provides StreetComplete-related statistics about users.
+* [StreetComplete-taginfo-categorize](https://github.com/mnalis/StreetComplete-taginfo-categorize) by [@mnalis](https://github.com/mnalis) generates tags listed in [KEYS_THAT_SHOULD_BE_REMOVED_WHEN_PLACE_IS_REPLACED](https://github.com/streetcomplete/StreetComplete/blob/master/app/src/main/java/de/westnordost/streetcomplete/osm/Place.kt#L244)
+* [All The Places <-> OpenStreetMap matcher](https://codeberg.org/matkoniecz/list_how_openstreetmap_can_be_improved_with_alltheplaces_data#all-the-places-openstreetmap-matcher) - for comparison between OpenStreetMap and All The Places. Produced dataset is listed at [this website](https://matkoniecz.codeberg.page/improving_openstreetmap_using_alltheplaces_dataset/) and powers quests that detect missing points of interests
* [StreetComplete-taginfo-categorize](https://github.com/mnalis/StreetComplete-taginfo-categorize) by [@mnalis](https://github.com/mnalis) generates tags listed in [KEYS_THAT_SHOULD_BE_REMOVED_WHEN_PLACE_IS_REPLACED](https://github.com/streetcomplete/StreetComplete/blob/master/app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/Places.kt#L248)
* [detect_missing_value_support_in_streetcomplete](https://codeberg.org/matkoniecz/detect_missing_value_support_in_streetcomplete.git) may manage to list tags missing from [opening_hours](app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt), [name](app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/place_name/AddPlaceName.kt), [wheelchair](app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/wheelchair_access/AddWheelchairAccessBusiness.kt) quests and [places](app/src/commonMain/kotlin/de/westnordost/streetcomplete/osm/Places.kt) overlay - and some of tags listed there may be in fact worth supporting in this parts of StreetComplete.
diff --git a/README.md b/README.md
index beb41ac5b62..68e2b005b79 100644
--- a/README.md
+++ b/README.md
@@ -50,10 +50,11 @@ This software is released under the terms of the [GNU General Public License](ht
## Sponsors

-The **NLnet foundation** sponsored development on this app in four individual grants with funds from the European Commission:
+The **NLnet foundation** sponsored development on this app in five individual grants with funds from the European Commission:
- A grant from 2025 will allow Tobias Zwick to finish migrating the app to a multiplatform, so that it runs also on iOS (see ticket)
- In 2021, a grant enabled Tobias Zwick to work about five months on the app - most notably, implement the overlays functionality and measuring with AR.
- In 2019 and 2021, Mateusz Konieczny each got a grant to work on StreetComplete with a focus on improvements on UI and data collection
+- In 2025, Mateusz Konieczny got a grant to work on [attempt to use All The Places dataset](https://github.com/streetcomplete/StreetComplete/pull/6302) within StreetComplete

diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpDaoTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpDaoTest.kt
new file mode 100644
index 00000000000..ce29d2e7713
--- /dev/null
+++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpDaoTest.kt
@@ -0,0 +1,124 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.ApplicationDbTestCase
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.Element
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.NODE
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.WAY
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.RELATION
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.ktx.containsExactlyInAnyOrder
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class AtpDaoTest : ApplicationDbTestCase() {
+ private lateinit var dao: AtpDao
+
+ @BeforeTest fun createDao() {
+ dao = AtpDao(database)
+ }
+
+ @Test fun putGet() {
+ val entry = createAtpEntry()
+
+ dao.put(entry)
+ val dbAtp = dao.get(entry.id)!!
+ assertEquals(entry, dbAtp)
+ }
+
+ @Test fun putAll() {
+ dao.putAll(listOf(createAtpEntry(1), createAtpEntry(2)))
+ assertNotNull(dao.get(1))
+ assertNotNull(dao.get(2))
+ }
+
+
+ @Test fun deleteButNothingIsThere() {
+ assertFalse(dao.delete(1))
+ }
+
+ @Test fun delete() {
+ val entry = createAtpEntry()
+ dao.put(entry)
+ assertTrue(dao.delete(entry.id))
+ assertNull(dao.get(entry.id))
+ assertFalse(dao.delete(entry.id))
+ }
+
+ @Test fun getAllPositions() {
+ val thisIsIn = createAtpEntry(1, LatLon(0.5, 0.5))
+ val thisIsOut = createAtpEntry(2, LatLon(-0.5, 0.5))
+ dao.putAll(listOf(thisIsIn, thisIsOut))
+
+ val positions = dao.getAllPositions(BoundingBox(0.0, 0.0, 1.0, 1.0))
+ assertEquals(LatLon(0.5, 0.5), positions.single())
+ }
+
+ @Test fun getAllByBbox() {
+ val thisIsIn = createAtpEntry(1, LatLon(0.5, 0.5))
+ val thisIsOut = createAtpEntry(2, LatLon(-0.5, 0.5))
+ dao.putAll(listOf(thisIsIn, thisIsOut))
+
+ val entries = dao.getAll(BoundingBox(0.0, 0.0, 1.0, 1.0))
+ assertEquals(thisIsIn, entries.single())
+ }
+
+ @Test fun getAllByIds() {
+ val first = createAtpEntry(1)
+ val second = createAtpEntry(2)
+ val third = createAtpEntry(3)
+ dao.putAll(listOf(first, second, third))
+
+ assertEquals(listOf(first, second), dao.getAll(listOf(1, 2)))
+ }
+
+ @Test fun deleteAllByIds() {
+ dao.putAll(listOf(createAtpEntry(1), createAtpEntry(2), createAtpEntry(3)))
+
+ assertEquals(2, dao.deleteAll(listOf(1, 2)))
+ assertNull(dao.get(1))
+ assertNull(dao.get(2))
+ assertNotNull(dao.get(3))
+ }
+
+ @Test fun getUnusedAndOldIds() {
+ dao.putAll(listOf(createAtpEntry(1), createAtpEntry(2), createAtpEntry(3)))
+ val unusedIds = dao.getIdsOlderThan(nowAsEpochMilliseconds() + 10)
+ assertTrue(unusedIds.containsExactlyInAnyOrder(listOf(1L, 2L, 3L)))
+ }
+
+ @Test fun getUnusedAndOldIdsButAtMostX() {
+ dao.putAll(listOf(createAtpEntry(1), createAtpEntry(2), createAtpEntry(3)))
+ val unusedIds = dao.getIdsOlderThan(nowAsEpochMilliseconds() + 10, 2)
+ assertEquals(2, unusedIds.size)
+ }
+
+ @Test fun clear() {
+ dao.putAll(listOf(createAtpEntry(1), createAtpEntry(2), createAtpEntry(3)))
+ dao.clear()
+ assertTrue(dao.getAll(listOf(1L, 2L, 3L)).isEmpty())
+ }
+}
+
+private fun createAtpEntry(
+ id: Long = 5,
+ position: LatLon = LatLon(1.0, 1.0),
+ osmMatch: ElementKey = ElementKey(NODE, 1),
+ tagsInATP: Map = mapOf(),
+ tagsInOSM: Map = mapOf(),
+ reportType: ReportType = ReportType.OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP,
+) = AtpEntry(
+ position = position,
+ id = id,
+ osmMatch = osmMatch,
+ tagsInATP = tagsInATP,
+ tagsInOSM = tagsInOSM,
+ reportType = reportType,
+ )
diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDaoTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDaoTest.kt
new file mode 100644
index 00000000000..4a9ceb4705b
--- /dev/null
+++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDaoTest.kt
@@ -0,0 +1,63 @@
+package de.westnordost.streetcomplete.data.atp.atpquests
+
+import de.westnordost.streetcomplete.data.ApplicationDbTestCase
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class AtpQuestsHiddenDaoTest : ApplicationDbTestCase() {
+ private lateinit var dao: AtpQuestsHiddenDao
+
+ @BeforeTest fun createDao() {
+ dao = AtpQuestsHiddenDao(database)
+ }
+
+ @Test fun addGetDelete() {
+ assertFalse(dao.delete(123L))
+ dao.add(123L)
+ assertNotNull(dao.getTimestamp(123L))
+ assertTrue(dao.delete(123L))
+ assertFalse(dao.delete(123L))
+ assertNull(dao.getTimestamp(123L))
+ }
+
+ @Test fun getAll() {
+ dao.add(1L)
+ dao.add(2L)
+ assertEquals(
+ setOf(1L, 2L),
+ dao.getAll().map { it.allThePlacesEntryId }.toSet()
+ )
+ }
+
+ @Test fun getNewerThan() = runBlocking {
+ dao.add(1L)
+ delay(200)
+ val time = nowAsEpochMilliseconds()
+ dao.add(2L)
+ val result = dao.getNewerThan(time - 100).single()
+ assertEquals(2L, result.allThePlacesEntryId)
+ }
+
+ @Test fun deleteAll() {
+ assertEquals(0, dao.deleteAll())
+ dao.add(1L)
+ dao.add(2L)
+ assertEquals(2, dao.deleteAll())
+ assertNull(dao.getTimestamp(1L))
+ assertNull(dao.getTimestamp(2L))
+ }
+
+ @Test fun countAll() {
+ assertEquals(0, dao.countAll())
+ dao.add(3L)
+ assertEquals(1, dao.countAll())
+ }
+}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/StreetCompleteApplication.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/StreetCompleteApplication.kt
index 090325c54af..7b8d1f7d7ba 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/StreetCompleteApplication.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/StreetCompleteApplication.kt
@@ -15,6 +15,9 @@ import de.westnordost.streetcomplete.data.CleanerWorker
import de.westnordost.streetcomplete.data.FeedsUpdater
import de.westnordost.streetcomplete.data.Preloader
import de.westnordost.streetcomplete.data.allEditTypesModule
+import de.westnordost.streetcomplete.data.atp.atpEditsModule
+import de.westnordost.streetcomplete.data.atp.atpModule
+import de.westnordost.streetcomplete.data.atp.atpquests.atpQuestModule
import de.westnordost.streetcomplete.data.download.downloadModule
import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController
import de.westnordost.streetcomplete.data.edithistory.EditHistoryController
@@ -125,6 +128,9 @@ class StreetCompleteApplication : Application() {
messagesModule,
osmApiModule,
osmNoteQuestModule,
+ atpEditsModule,
+ atpModule,
+ atpQuestModule,
osmQuestModule,
preferencesModule,
questModule,
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestModule.kt
index cad63c53f9d..174aba9f7bf 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestModule.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestModule.kt
@@ -4,5 +4,5 @@ import org.koin.dsl.module
val questModule = module {
single { QuestAutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
- single { VisibleQuestsSource(get(), get(), get(), get(), get(), get(), get()) }
+ single { VisibleQuestsSource(get(), get(), get(), get(), get(), get(), get(), get()) }
}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/AtpCreateForm.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/AtpCreateForm.kt
new file mode 100644
index 00000000000..1909cdd30b8
--- /dev/null
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/AtpCreateForm.kt
@@ -0,0 +1,153 @@
+package de.westnordost.streetcomplete.data.quest.atp
+
+import android.location.Location
+import android.os.Bundle
+import android.view.View
+import androidx.core.os.bundleOf
+import de.westnordost.osmfeatures.FeatureDictionary
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.atp.AtpEntry
+import de.westnordost.streetcomplete.data.location.SurveyChecker
+import de.westnordost.streetcomplete.data.osm.edits.AddElementEditsController
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditType
+import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
+import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction
+import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.osm.mapdata.Node
+import de.westnordost.streetcomplete.data.quest.QuestKey
+import de.westnordost.streetcomplete.data.visiblequests.HideQuestController
+import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController
+import de.westnordost.streetcomplete.quests.AbstractQuestForm
+import de.westnordost.streetcomplete.quests.AnswerItem
+import de.westnordost.streetcomplete.util.getNameAndLocationSpanned
+import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope
+import de.westnordost.streetcomplete.view.confirmIsSurvey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import org.koin.android.ext.android.inject
+import org.koin.core.qualifier.named
+import kotlin.getValue
+
+class AtpCreateForm : AbstractQuestForm() {
+ private val hiddenQuestsController: QuestsHiddenController by inject()
+ private val featureDictionaryLazy: Lazy by inject(named("FeatureDictionaryLazy"))
+ private val elementEditsController: ElementEditsController by inject()
+ private val surveyChecker: SurveyChecker by inject()
+
+ override val contentLayoutResId = R.layout.quest_atp_create
+ private lateinit var entry: AtpEntry private set
+ private val featureDictionary: FeatureDictionary get() = featureDictionaryLazy.value
+ var hideQuestController: HideQuestController = hiddenQuestsController
+ var selectedLocation: LatLon? = null
+ var addElementEditsController: AddElementEditsController = elementEditsController
+
+ override fun onClickMapAt(position: LatLon, clickAreaSizeInMeters: Double): Boolean {
+ selectedLocation = position
+ checkIsFormComplete()
+ return true
+ }
+
+ override fun onClickOk() {
+ if(selectedLocation == null) {
+ return
+ } else {
+ viewLifecycleScope.launch { // viewLifecycleScope is here via cargo cult - what it is doing and is it needed TODO
+ applyEdit(CreateNodeAction(selectedLocation!!, entry.tagsInATP))
+ }
+ }
+ }
+
+ protected fun applyEdit(answer: ElementEditAction, geometry: ElementGeometry = this.geometry) {
+ viewLifecycleScope.launch {
+ solve(answer, geometry)
+ }
+ }
+
+ private suspend fun solve(action: ElementEditAction, geometry: ElementGeometry) {
+ setLocked(true)
+ val isSurvey = surveyChecker.checkIsSurvey(geometry)
+ if (!isSurvey && !confirmIsSurvey(requireContext())) {
+ setLocked(false)
+ return
+ }
+
+ withContext(Dispatchers.IO) {
+ addElementEditsController.add(CreatePoiBasedOnAtp, geometry, "survey", action, isSurvey)
+ }
+ listener?.onEdited(CreatePoiBasedOnAtp, geometry)
+ }
+ override fun isFormComplete(): Boolean {
+ return selectedLocation != null
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val args = requireArguments()
+ entry = Json.decodeFromString(args.getString(ATP_ENTRY)!!)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // TODO: maybe should be more prominent?
+ setTitleHintLabel(getNameAndLocationSpanned(Node(
+ 1,
+ position = entry.position,
+ tags = entry.tagsInATP,
+ version = 1,
+ timestampEdited = 1,
+ ), resources, featureDictionary))
+ }
+
+ override fun onStart() {
+ super.onStart()
+ updateButtonPanel()
+ }
+
+ protected fun updateButtonPanel() {
+ // TODO: create answers to send to API, not just hide quests
+ val mappedAlready = AnswerItem(R.string.quest_atp_add_missing_poi_mapped_already) { /*applyAnswer(false)*/ hideQuest() }
+ val missing = AnswerItem(R.string.quest_atp_add_missing_poi_does_not_exist) { /*applyAnswer(true)*/ hideQuest() }
+ val cantSay = AnswerItem(R.string.quest_generic_answer_notApplicable) { hideQuest() /* no option to leave note */ }
+
+ setButtonPanelAnswers(listOf(mappedAlready, missing, cantSay))
+ }
+
+ interface Listener {
+ /** The GPS position at which the user is displayed at */
+ val displayedMapLocation: Location?
+
+ /** Called when the user successfully answered the quest */
+ fun onEdited(editType: ElementEditType, geometry: ElementGeometry)
+
+ // TODO API actually use that (or remove, if not needed)
+ fun onRejectedAtpEntry(editType: ElementEditType, geometry: ElementGeometry)
+
+ /** Called when the user chose to move the node */
+ fun onMoveNode(editType: ElementEditType, node: Node)
+
+ /** Called when the user chose to hide the quest instead */
+ fun onQuestHidden(questKey: QuestKey)
+ }
+ private val listener: Listener? get() = parentFragment as? Listener ?: activity as? Listener
+
+ protected fun hideQuest() {
+ viewLifecycleScope.launch {
+ withContext(Dispatchers.IO) { hideQuestController.hide(questKey) }
+ listener?.onQuestHidden(questKey)
+ }
+ }
+
+ companion object {
+ private const val ATP_ENTRY = "atp_entry"
+
+ fun createArguments(entry: AtpEntry) = bundleOf(
+ ATP_ENTRY to Json.encodeToString(entry)
+ )
+ }
+}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtp.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtp.kt
new file mode 100644
index 00000000000..fe3e617bd06
--- /dev/null
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtp.kt
@@ -0,0 +1,22 @@
+package de.westnordost.streetcomplete.data.quest.atp
+
+import de.westnordost.osmfeatures.Feature
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.osm.mapdata.Element
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry
+import de.westnordost.streetcomplete.data.quest.AndroidQuest
+import de.westnordost.streetcomplete.data.quest.OsmCreateElementQuestType
+import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.CITIZEN
+import de.westnordost.streetcomplete.osm.isPlaceOrDisusedPlace
+
+object CreatePoiBasedOnAtp : OsmCreateElementQuestType, AndroidQuest {
+ override fun createForm() = AtpCreateForm()
+ override val icon = R.drawable.quest_dot // TODO LATER: a radar icon? A plus icon? See https://github.com/streetcomplete/StreetComplete/pull/6302#issuecomment-3046628887
+ override val title = R.string.quest_atp_add_missing_poi_title
+ override val wikiLink = "All the Places"
+ override val achievements = listOf(CITIZEN)
+ override val changesetComment = "Create POI surveyed by mapper, hint about missing entry was based on AllThePlaces data"
+
+ override fun getHighlightedElementsGeneric(element: Element?, getMapData: () -> MapDataWithGeometry) =
+ getMapData().asSequence().filter { it.isPlaceOrDisusedPlace() }
+}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtpAnswer.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtpAnswer.kt
new file mode 100644
index 00000000000..dcfdae59f50
--- /dev/null
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/quest/atp/CreatePoiBasedOnAtpAnswer.kt
@@ -0,0 +1,5 @@
+package de.westnordost.streetcomplete.data.quest.atp
+
+class CreatePoiBasedOnAtpAnswer {
+
+}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/QuestsModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/QuestsModule.kt
index 41b799c6e12..21a4da24100 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/QuestsModule.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/quests/QuestsModule.kt
@@ -9,6 +9,7 @@ import de.westnordost.streetcomplete.data.meta.getByLocation
import de.westnordost.streetcomplete.data.osm.mapdata.Element
import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry
+import de.westnordost.streetcomplete.data.quest.atp.CreatePoiBasedOnAtp
import de.westnordost.streetcomplete.quests.accepts_cards.AddAcceptsCards
import de.westnordost.streetcomplete.quests.accepts_cash.AddAcceptsCash
import de.westnordost.streetcomplete.quests.access_point_ref.AddAccessPointRef
@@ -256,6 +257,8 @@ fun questTypeRegistry(
even if the quest's order is changed or new quests are added somewhere in the middle. Each new
quest always gets a new sequential ordinal.
*/
+ // TODO LATER: move this quests in appropriate location as far as priority goes
+ 200 to CreatePoiBasedOnAtp, // reduce id if needed TODO
/* always first: notes - they mark a mistake in the data so potentially every quest for that
element is based on wrong data while the note is not resolved */
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt
index 63f741fe624..8ed724762e7 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt
@@ -38,6 +38,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import de.westnordost.osmfeatures.FeatureDictionary
import de.westnordost.streetcomplete.ApplicationConstants
import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.atp.AtpEntry
+import de.westnordost.streetcomplete.data.atp.atpquests.CreateElementUsingAtpQuest
+import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpDataWithEditsSource
import de.westnordost.streetcomplete.data.FeedsUpdater
import de.westnordost.streetcomplete.data.download.tiles.asBoundingBoxOfEnclosingTiles
import de.westnordost.streetcomplete.data.edithistory.EditKey
@@ -66,6 +69,7 @@ import de.westnordost.streetcomplete.data.quest.QuestAutoSyncer
import de.westnordost.streetcomplete.data.quest.QuestKey
import de.westnordost.streetcomplete.data.quest.QuestType
import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource
+import de.westnordost.streetcomplete.data.quest.atp.AtpCreateForm
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource
import de.westnordost.streetcomplete.databinding.ActivityMainBinding
import de.westnordost.streetcomplete.databinding.EffectQuestPlopBinding
@@ -151,12 +155,14 @@ class MainActivity :
AbstractOverlayForm.Listener,
SplitWayFragment.Listener,
NoteDiscussionForm.Listener,
+ AtpCreateForm.Listener,
LeaveNoteInsteadFragment.Listener,
CreateNoteFragment.Listener,
MoveNodeFragment.Listener,
// listeners to changes to data:
VisibleQuestsSource.Listener,
MapDataWithEditsSource.Listener,
+ AtpDataWithEditsSource.Listener,
// rest
ShowsGeometryMarkers {
@@ -165,6 +171,7 @@ class MainActivity :
private val prefs: Preferences by inject()
private val visibleQuestsSource: VisibleQuestsSource by inject()
private val mapDataWithEditsSource: MapDataWithEditsSource by inject()
+ private val atpDataWithEditsSource: AtpDataWithEditsSource by inject()
private val notesSource: NotesWithEditsSource by inject()
private val questsHiddenSource: QuestsHiddenSource by inject()
private val feedsUpdater: FeedsUpdater by inject()
@@ -294,6 +301,7 @@ class MainActivity :
visibleQuestsSource.addListener(this)
mapDataWithEditsSource.addListener(this)
+ atpDataWithEditsSource.addListener(this)
locationAvailabilityReceiver.addListener(::updateLocationAvailability)
updateLocationAvailability(isLocationAvailable)
@@ -320,6 +328,7 @@ class MainActivity :
visibleQuestsSource.removeListener(this)
mapDataWithEditsSource.removeListener(this)
+ atpDataWithEditsSource.removeListener(this)
locationAvailabilityReceiver.removeListener(::updateLocationAvailability)
locationManager.removeUpdates()
@@ -532,6 +541,15 @@ class MainActivity :
closeBottomSheet()
}
+ /* ------------------------------ AtpDiscussionForm.Listener ------------------------------- */
+
+ override fun onRejectedAtpEntry(
+ editType: ElementEditType,
+ geometry: ElementGeometry,
+ ) {
+ closeBottomSheet()
+ }
+
/* ------------------------------- CreateNoteFragment.Listener ------------------------------ */
override fun onCreatedNote(position: LatLon) {
@@ -605,6 +623,8 @@ class MainActivity :
if (openElement == null) {
closeBottomSheet()
}
+ // TODO: do I need support for ATP handling? probably yes
+ // I need to detect whether quest referring to now gone ATP entry is opened
}
}
@@ -618,6 +638,22 @@ class MainActivity :
}
}
+ /* ---------------------------- AtpDataWithEditsSource.Listener ----------------------------- */
+
+ @AnyThread
+ override fun onUpdatedAtpElements(added: Collection, deleted: Collection) {
+ // TODO: support ATP handling - it is likely needed
+ /*
+ lifecycleScope.launch {
+ val f = bottomSheetFragment
+ // open element has been deleted
+ if (f is IsShowingElement && f.elementKey in deleted) {
+ closeBottomSheet()
+ }
+ }
+ */
+ }
+
//endregion
/* ++++++++++++++++++++++++++++++++++++++ VIEW CONTROL ++++++++++++++++++++++++++++++++++++++ */
@@ -995,7 +1031,11 @@ class MainActivity :
f.requireArguments().putAll(osmArgs)
showHighlightedElements(quest, element)
}
-
+ if (f is AtpCreateForm && quest is CreateElementUsingAtpQuest) {
+ val passingAtpArgs = AtpCreateForm.createArguments(quest.atpEntry)
+ f.requireArguments().putAll(passingAtpArgs)
+ showHighlightedElementsAroundAtpEntryQuest(quest, quest.atpEntry)
+ }
showInBottomSheet(f)
mapFragment.startFocus(quest.geometry, getQuestFormInsets())
@@ -1006,7 +1046,32 @@ class MainActivity :
}
private fun showHighlightedElements(quest: OsmQuest, element: Element) {
- val bbox = quest.geometry.bounds.enlargedBy(quest.type.highlightedElementsRadius)
+ return showHighlightedElementsShared(
+ quest,
+ element.tags,
+ element,
+ quest.type.highlightedElementsRadius
+ )
+ }
+
+ private fun showHighlightedElementsAroundAtpEntryQuest(
+ quest: CreateElementUsingAtpQuest,
+ atpEntry: AtpEntry,
+ ) {
+ // TODO is merge with showHighlightedElements a good idea?
+ // some challenges: it is not passing or having an element - changed that for nullable parameter - is it fine? Maybe effectively duplicating this function is nicer?
+ // passing highlightedElementsRadius is silly (maybe create interface used by both classes?)
+ val tags = atpEntry.tagsInATP
+ showHighlightedElementsShared(quest, tags, null, quest.type.highlightedElementsRadius)
+ }
+
+ private fun showHighlightedElementsShared(
+ quest: Quest,
+ tags: Map,
+ element: Element?,
+ highlightedElementsRadius: Double,
+ ) {
+ val bbox = quest.geometry.bounds.enlargedBy(highlightedElementsRadius)
var mapData: MapDataWithGeometry? = null
fun getMapData(): MapDataWithGeometry {
@@ -1015,11 +1080,11 @@ class MainActivity :
return data
}
- val levels = parseLevelsOrNull(element.tags)
+ val levels = parseLevelsOrNull(tags)
lifecycleScope.launch(Dispatchers.Default) {
val elements = withContext(Dispatchers.IO) {
- quest.type.getHighlightedElements(element, ::getMapData)
+ quest.type.getHighlightedElementsGeneric(element, ::getMapData)
}
val markers = elements.mapNotNull { e ->
@@ -1029,7 +1094,7 @@ class MainActivity :
val eLevels = parseLevelsOrNull(e.tags)
if (!levels.levelsIntersect(eLevels)) return@mapNotNull null
// include only elements with the same layer, if any
- if (element.tags["layer"] != e.tags["layer"]) return@mapNotNull null
+ if (tags["layer"] != e.tags["layer"]) return@mapNotNull null
val geometry = mapData?.getGeometry(e.type, e.id) ?: return@mapNotNull null
val icon = getIcon(featureDictionary.value, e)
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt
index 82162672051..9529044ac6f 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/Edit.kt
@@ -3,6 +3,7 @@ package de.westnordost.streetcomplete.screens.main.edithistory
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHidden
import de.westnordost.streetcomplete.data.edithistory.Edit
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.delete.DeletePoiNodeAction
@@ -14,6 +15,7 @@ import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.COMMENT
import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditAction.CREATE
import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden
import de.westnordost.streetcomplete.data.quest.QuestType
+import de.westnordost.streetcomplete.data.quest.atp.CreatePoiBasedOnAtp
import de.westnordost.streetcomplete.quests.getTitle
import de.westnordost.streetcomplete.resources.Res
import de.westnordost.streetcomplete.resources.commented_note_action_title
@@ -36,6 +38,7 @@ val Edit.icon: Int get() = when (this) {
}
is OsmNoteQuestHidden -> R.drawable.quest_notes
is OsmQuestHidden -> questType.icon
+ is AtpQuestHidden -> CreatePoiBasedOnAtp.icon
else -> 0
}
@@ -50,6 +53,7 @@ val Edit.overlayIcon: DrawableResource? get() = when (this) {
}
is OsmNoteQuestHidden -> Res.drawable.undo_visibility
is OsmQuestHidden -> Res.drawable.undo_visibility
+ is AtpQuestHidden -> Res.drawable.undo_visibility
else -> null
}
@@ -75,5 +79,8 @@ fun Edit.getTitle(elementTags: Map?): String = when (this) {
is OsmNoteQuestHidden -> {
stringResource(Res.string.quest_noteDiscussion_title)
}
+ is AtpQuestHidden -> {
+ stringResource(R.string.quest_atp_add_missing_poi_title)
+ }
else -> throw IllegalArgumentException()
}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt
index 987b8084947..91ce379c5e9 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/edithistory/EditDescription.kt
@@ -5,6 +5,9 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHidden
import de.westnordost.streetcomplete.data.edithistory.Edit
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.create.CreateNodeAction
@@ -66,6 +69,8 @@ fun EditDescription(
Text(stringResource(Res.string.hid_action_description), modifier)
is OsmNoteQuestHidden ->
Text(stringResource(Res.string.hid_action_description), modifier)
+ is AtpQuestHidden ->
+ Text(stringResource(Res.string.hid_action_description), modifier)
}
}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt
index eff5a0a6eeb..4d9df14d5ec 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/EditHistoryPinsManager.kt
@@ -2,6 +2,7 @@ package de.westnordost.streetcomplete.screens.main.map
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHidden
import de.westnordost.streetcomplete.data.edithistory.Edit
import de.westnordost.streetcomplete.data.edithistory.EditHistorySource
import de.westnordost.streetcomplete.data.edithistory.EditKey
@@ -13,6 +14,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden
import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEdit
import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden
+import de.westnordost.streetcomplete.data.quest.AtpQuestKey
import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey
import de.westnordost.streetcomplete.data.quest.OsmQuestKey
import de.westnordost.streetcomplete.screens.main.edithistory.icon
@@ -108,11 +110,13 @@ private const val MARKER_ELEMENT_ID = "element_id"
private const val MARKER_QUEST_TYPE = "quest_type"
private const val MARKER_NOTE_ID = "note_id"
private const val MARKER_ID = "id"
+private const val ATP_ENTRY_ID = "atp_id"
private const val EDIT_TYPE_ELEMENT = "element"
private const val EDIT_TYPE_NOTE = "note"
private const val EDIT_TYPE_HIDE_OSM_NOTE_QUEST = "hide_osm_note_quest"
private const val EDIT_TYPE_HIDE_OSM_QUEST = "hide_osm_quest"
+private const val EDIT_TYPE_HIDE_ATP_QUEST = "hide_atp_quest"
private fun Edit.toProperties(): List> = when (this) {
is ElementEdit -> listOf(
@@ -133,6 +137,10 @@ private fun Edit.toProperties(): List> = when (this) {
MARKER_ELEMENT_ID to elementId.toString(),
MARKER_QUEST_TYPE to questType.name
)
+ is AtpQuestHidden -> listOf(
+ MARKER_EDIT_TYPE to EDIT_TYPE_HIDE_ATP_QUEST,
+ ATP_ENTRY_ID to atpEntry.id.toString()
+ )
else -> throw IllegalArgumentException()
}
@@ -149,5 +157,7 @@ private fun Map.toEditKey(): EditKey? = when (get(MARKER_EDIT_TY
))
EDIT_TYPE_HIDE_OSM_NOTE_QUEST ->
QuestHiddenKey(OsmNoteQuestKey(getValue(MARKER_NOTE_ID).toLong()))
+ EDIT_TYPE_HIDE_ATP_QUEST ->
+ QuestHiddenKey(AtpQuestKey(getValue(ATP_ENTRY_ID).toLong()))
else -> null
}
diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt
index 68f65882194..146a8c043c6 100644
--- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt
+++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/QuestPinsManager.kt
@@ -6,6 +6,7 @@ import de.westnordost.streetcomplete.data.download.tiles.TilesRect
import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect
import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
+import de.westnordost.streetcomplete.data.quest.AtpQuestKey
import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey
import de.westnordost.streetcomplete.data.quest.OsmQuestKey
import de.westnordost.streetcomplete.data.quest.Quest
@@ -259,18 +260,26 @@ private const val MARKER_NOTE_ID = "note_id"
private const val QUEST_GROUP_OSM = "osm"
private const val QUEST_GROUP_OSM_NOTE = "osm_note"
+private const val QUEST_GROUP_ATP = "atp"
+private const val MARKER_ATP_ENTRY_ID = "atp_entry_id"
private fun QuestKey.toProperties(): List> = when (this) {
is OsmNoteQuestKey -> listOf(
MARKER_QUEST_GROUP to QUEST_GROUP_OSM_NOTE,
MARKER_NOTE_ID to noteId.toString()
)
+
is OsmQuestKey -> listOf(
MARKER_QUEST_GROUP to QUEST_GROUP_OSM,
MARKER_ELEMENT_TYPE to elementType.name,
MARKER_ELEMENT_ID to elementId.toString(),
MARKER_QUEST_TYPE to questTypeName
)
+
+ is AtpQuestKey -> listOf(
+ MARKER_QUEST_GROUP to QUEST_GROUP_ATP,
+ MARKER_ATP_ENTRY_ID to atpEntryId.toString()
+ )
}
private fun Map.toQuestKey(): QuestKey? = when (get(MARKER_QUEST_GROUP)) {
@@ -282,5 +291,7 @@ private fun Map.toQuestKey(): QuestKey? = when (get(MARKER_QUEST
getValue(MARKER_ELEMENT_ID).toLong(),
getValue(MARKER_QUEST_TYPE)
)
+ QUEST_GROUP_ATP ->
+ AtpQuestKey(getValue(MARKER_ATP_ENTRY_ID).toLong())
else -> null
}
diff --git a/app/src/androidMain/res/layout/quest_atp_create.xml b/app/src/androidMain/res/layout/quest_atp_create.xml
new file mode 100644
index 00000000000..8ef14f40b7c
--- /dev/null
+++ b/app/src/androidMain/res/layout/quest_atp_create.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/androidMain/res/values/strings.xml b/app/src/androidMain/res/values/strings.xml
index cec00730351..25f1a68fa0b 100644
--- a/app/src/androidMain/res/values/strings.xml
+++ b/app/src/androidMain/res/values/strings.xml
@@ -733,6 +733,11 @@ Before uploading your changes, the app checks with a <a href="https://www.wes
Is this place air-conditioned?
+ This place may be in this area - where exactly it is located?
+ Tap on its exact location on the map to mark its real location.
+ "It's already displayed on the map"
+ "No such place here"
+
Can you deposit cash at this ATM?
Which bank operates this ATM?
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClientTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClientTest.kt
new file mode 100644
index 00000000000..1008e8736dc
--- /dev/null
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClientTest.kt
@@ -0,0 +1,142 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.ApiClientException
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.testutils.any
+import de.westnordost.streetcomplete.testutils.atpEntry
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import de.westnordost.streetcomplete.util.logs.Log
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respondBadRequest
+import io.ktor.client.engine.mock.respondOk
+import kotlinx.coroutines.runBlocking
+import kotlinx.io.Source
+import kotlinx.io.readString
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class AtpApiClientTest {
+ private val apiParser: AtpApiParser = mock()
+
+ private val validResponseMockEngine = MockEngine { respondOk("simple response") }
+
+ @Test fun `download parses all atp entries`() = runBlocking {
+ val bounds = BoundingBox(0.1, 0.1, 0.2, 0.2)
+ val client = AtpApiClient(HttpClient(validResponseMockEngine), "", apiParser)
+ val response = listOf(atpEntry(), atpEntry())
+ on(apiParser.parseAtpEntries(any())).thenReturn(response)
+ assertEquals(response, client.getAllAtpEntries(bounds))
+ }
+
+ @Test fun `download throws Exception for a 400 response`(): Unit = runBlocking {
+ val bounds = BoundingBox(0.1, 0.1, 0.2, 0.2)
+ val mockEngine = MockEngine { _ -> respondBadRequest() }
+ val client = AtpApiClient(HttpClient(mockEngine), "", apiParser)
+ assertFailsWith { client.getAllAtpEntries(bounds) }
+ }
+
+ @Test fun `download handles split into two graticules on longitude axis`() = runBlocking {
+ val bounds = BoundingBox(0.1, -0.1, 0.2, 0.1)
+ val splitGraticulesResponseMockEngine = MockEngine { request ->
+ when (request.url.toString()) {
+ "http://localhost/lat_0/lon_-1_gathered.geojson" -> respondOk("NEGATIVE_LONGITUDE_DATA")
+ "http://localhost/lat_0/lon_0_gathered.geojson" -> respondOk("ZERO_LONGITUDE_DATA")
+ else -> {
+ respondBadRequest()
+ }
+ }
+ }
+
+ val negative = atpEntry(id = 1)
+ val positive = atpEntry(id = 2)
+ on(apiParser.parseAtpEntries(any())).thenAnswer { invocation ->
+ val source = invocation.getArgument(0)
+
+ when (source.readString()) {
+ "NEGATIVE_LONGITUDE_DATA" -> listOf(negative)
+ "ZERO_LONGITUDE_DATA" -> listOf(positive)
+ else -> null
+ }
+ }
+ val client = AtpApiClient(HttpClient(splitGraticulesResponseMockEngine), "", apiParser)
+ val response = setOf(negative, positive)
+
+ assertEquals(2, client.getAllAtpEntries(bounds).size) // ensure no duplicates
+ assertEquals(response, client.getAllAtpEntries(bounds).toSet()) // any order is fine
+ }
+
+ @Test fun `download handles split into four graticules`() = runBlocking {
+ val bounds = BoundingBox(19.9, 49.9, 20.2, 50.1)
+ val splitGraticulesResponseMockEngine = MockEngine { request ->
+ when (request.url.toString()) {
+ "http://localhost/lat_19/lon_49_gathered.geojson" -> respondOk("MIN_LAT_MIN_LON")
+ "http://localhost/lat_19/lon_50_gathered.geojson" -> respondOk("MIN_LAT_MAX_LON")
+ "http://localhost/lat_20/lon_49_gathered.geojson" -> respondOk("MAX_LAT_MIN_LON")
+ "http://localhost/lat_20/lon_50_gathered.geojson" -> respondOk("MAX_LAT_MAX_LON")
+ else -> {
+ respondBadRequest()
+ }
+ }
+ }
+
+ val first = atpEntry(id = 1)
+ val second = atpEntry(id = 2)
+ val third = atpEntry(id = 3)
+ val fourth = atpEntry(id = 4)
+ on(apiParser.parseAtpEntries(any())).thenAnswer { invocation ->
+ val source = invocation.getArgument(0)
+
+ when (source.readString()) {
+ "MIN_LAT_MIN_LON" -> listOf(first)
+ "MIN_LAT_MAX_LON" -> listOf(second)
+ "MAX_LAT_MIN_LON" -> listOf(third)
+ "MAX_LAT_MAX_LON" -> listOf(fourth)
+ else -> null
+ }
+ }
+ val client = AtpApiClient(HttpClient(splitGraticulesResponseMockEngine), "", apiParser)
+ val response = setOf(first, second, third, fourth)
+
+ assertEquals(4, client.getAllAtpEntries(bounds).size) // ensure no duplicates
+ assertEquals(response, client.getAllAtpEntries(bounds).toSet()) // any order is fine
+ }
+
+ @Test fun `download handles split into two graticules on latitude axis`() = runBlocking {
+ val bounds = BoundingBox(19.9, 50.8, 20.2, 50.9)
+ val splitGraticulesResponseMockEngine = MockEngine { request ->
+ when (request.url.toString()) {
+ "http://localhost/lat_19/lon_49_gathered.geojson" -> respondOk("MIN_LAT_MIN_LON")
+ "http://localhost/lat_19/lon_50_gathered.geojson" -> respondOk("MIN_LAT_MAX_LON")
+ "http://localhost/lat_20/lon_49_gathered.geojson" -> respondOk("MAX_LAT_MIN_LON")
+ "http://localhost/lat_20/lon_50_gathered.geojson" -> respondOk("MAX_LAT_MAX_LON")
+ else -> {
+ respondBadRequest()
+ }
+ }
+ }
+
+ val first = atpEntry(id = 1)
+ val second = atpEntry(id = 2)
+ val third = atpEntry(id = 3)
+ val fourth = atpEntry(id = 4)
+ on(apiParser.parseAtpEntries(any())).thenAnswer { invocation ->
+ val source = invocation.getArgument(0)
+
+ when (source.readString()) {
+ "MIN_LAT_MIN_LON" -> listOf(first)
+ "MIN_LAT_MAX_LON" -> listOf(second)
+ "MAX_LAT_MIN_LON" -> listOf(third)
+ "MAX_LAT_MAX_LON" -> listOf(fourth)
+ else -> null
+ }
+ }
+ val client = AtpApiClient(HttpClient(splitGraticulesResponseMockEngine), "", apiParser)
+ val response = setOf(second, fourth)
+
+ assertEquals(2, client.getAllAtpEntries(bounds).size) // ensure no duplicates
+ assertEquals(response, client.getAllAtpEntries(bounds).toSet()) // any order is fine
+ }
+}
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpControllerTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpControllerTest.kt
new file mode 100644
index 00000000000..0d71acbfe92
--- /dev/null
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpControllerTest.kt
@@ -0,0 +1,142 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.testutils.atpEntry
+import de.westnordost.streetcomplete.testutils.bbox
+import de.westnordost.streetcomplete.testutils.eq
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.on
+import de.westnordost.streetcomplete.testutils.p
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoInteractions
+import java.lang.Thread.sleep
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AtpControllerTest {
+ private lateinit var dao: AtpDao
+ private lateinit var atpController: AtpController
+
+ @BeforeTest fun setUp() {
+ dao = mock()
+ atpController = AtpController(dao)
+ }
+
+ @Test fun get() {
+ val atpEntry = atpEntry(5)
+ on(dao.get(5L)).thenReturn(atpEntry)
+ assertEquals(atpEntry, atpController.get(5L))
+ }
+
+ @Test fun `getAll entry ids`() {
+ val ids = listOf(1L, 2L, 3L)
+ val ret = listOf(atpEntry(1), atpEntry(2), atpEntry(3))
+ on(dao.getAll(ids)).thenReturn(ret)
+ assertEquals(ret, atpController.getAll(ids))
+ }
+
+ @Test fun `getAll in bbox`() {
+ val bbox = bbox()
+ val ret = listOf(atpEntry(1), atpEntry(2), atpEntry(3))
+ on(dao.getAll(bbox)).thenReturn(ret)
+ assertEquals(ret, atpController.getAll(bbox))
+ }
+
+ @Test fun `getAllPositions in bbox`() {
+ val bbox = bbox()
+ val ret = listOf(p(), p(), p())
+ on(dao.getAllPositions(bbox)).thenReturn(ret)
+ assertEquals(ret, atpController.getAllPositions(bbox))
+ }
+
+ @Test fun delete() {
+ val listener = mock()
+ on(dao.delete(1L)).thenReturn(true)
+
+ atpController.addListener(listener)
+ atpController.delete(1L)
+ verify(dao).delete(1L)
+
+ sleep(100)
+ verify(listener).onUpdated(eq(emptyList()), eq(emptyList()), eq(listOf(1L)))
+ }
+
+ @Test fun `delete non-existing`() {
+ val listener = mock()
+ on(dao.delete(1L)).thenReturn(false)
+
+ atpController.addListener(listener)
+ atpController.delete(1L)
+ verify(dao).delete(1L)
+ verifyNoInteractions(listener)
+ }
+
+ @Test fun `remove listener`() {
+ val listener = mock()
+
+ atpController.addListener(listener)
+ atpController.removeListener(listener)
+ atpController.clear()
+ verifyNoInteractions(listener)
+ }
+
+ @Test fun deleteOlderThan() {
+ val ids = listOf(1L, 2L, 3L)
+ on(dao.getIdsOlderThan(123L)).thenReturn(ids)
+ val listener = mock()
+
+ atpController.addListener(listener)
+
+ assertEquals(3, atpController.deleteOlderThan(123L))
+ verify(dao).deleteAll(ids)
+
+ sleep(100)
+ verify(listener).onUpdated(eq(emptyList()), eq(emptyList()), eq(ids))
+ }
+
+ @Test fun clear() {
+ val listener = mock()
+ atpController.addListener(listener)
+ atpController.clear()
+
+ verify(dao).clear()
+ verify(listener).onCleared()
+ }
+
+ @Test fun `putAllForBBox when nothing was there before`() {
+ val bbox = bbox()
+ val atpEntries = listOf(atpEntry(1), atpEntry(2), atpEntry(3))
+ on(dao.getAll(bbox)).thenReturn(emptyList())
+ val listener = mock()
+
+ atpController.addListener(listener)
+ atpController.putAllForBBox(bbox, atpEntries)
+ verify(dao).getAll(bbox)
+ verify(dao).putAll(eq(atpEntries))
+ verify(dao).deleteAll(eq(emptySet()))
+
+ sleep(100)
+ verify(listener).onUpdated(eq(atpEntries), eq(emptyList()), eq(emptySet()))
+ }
+
+ @Test fun `putAllForBBox when there is something already`() {
+ val atpEntry1 = atpEntry(1)
+ val atpEntry2 = atpEntry(2)
+ val atpEntry3 = atpEntry(3)
+ val bbox = bbox()
+ val oldNotes = listOf(atpEntry1, atpEntry2)
+ // 1 is updated, 2 is deleted, 3 is added
+ val newNotes = listOf(atpEntry1, atpEntry3)
+ on(dao.getAll(bbox)).thenReturn(oldNotes)
+ val listener = mock()
+
+ atpController.addListener(listener)
+ atpController.putAllForBBox(bbox, newNotes)
+ verify(dao).getAll(bbox)
+ verify(dao).putAll(eq(newNotes))
+ verify(dao).deleteAll(eq(setOf(2L)))
+
+ sleep(100)
+ verify(listener).onUpdated(eq(listOf(atpEntry3)), eq(listOf(atpEntry1)), eq(setOf(2)))
+ }
+}
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestControllerTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestControllerTest.kt
new file mode 100644
index 00000000000..a0058f0914c
--- /dev/null
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestControllerTest.kt
@@ -0,0 +1,470 @@
+package de.westnordost.streetcomplete.data.atp.atpquests
+
+import de.westnordost.streetcomplete.data.atp.AtpEntry
+import de.westnordost.streetcomplete.data.atp.ReportType
+import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpDataWithEditsSource
+import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource
+import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometryEntry
+import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry
+import de.westnordost.streetcomplete.data.osm.mapdata.Element
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType.NODE
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry
+import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapDataWithGeometry
+import de.westnordost.streetcomplete.data.preferences.Preferences
+import de.westnordost.streetcomplete.data.quest.OsmCreateElementQuestType
+import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry
+import de.westnordost.streetcomplete.data.user.UserDataSource
+import de.westnordost.streetcomplete.data.user.UserLoginSource
+import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement
+import de.westnordost.streetcomplete.testutils.any
+import de.westnordost.streetcomplete.testutils.atpEntry
+import de.westnordost.streetcomplete.testutils.bbox
+import de.westnordost.streetcomplete.testutils.mock
+import de.westnordost.streetcomplete.testutils.node
+import de.westnordost.streetcomplete.testutils.on
+import de.westnordost.streetcomplete.testutils.pGeom
+import de.westnordost.streetcomplete.util.math.enclosingBoundingBox
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class AtpQuestControllerTest {
+
+ private lateinit var mapDataSource: MapDataWithEditsSource
+ private lateinit var atpDataSource: AtpDataWithEditsSource
+ private lateinit var userDataSource: UserDataSource
+ private lateinit var userLoginSource: UserLoginSource
+ private lateinit var prefs: Preferences
+ private lateinit var registry: QuestTypeRegistry
+
+ private lateinit var ctrl: AtpQuestController
+ private lateinit var listener: AtpQuestSource.Listener
+
+ private lateinit var userLoginListener: UserLoginSource.Listener
+ private lateinit var atpUpdatesListener: AtpDataWithEditsSource.Listener
+
+ private lateinit var mapDataListener: MapDataWithEditsSource.Listener
+
+ @BeforeTest
+ fun setUp() {
+ mapDataSource = mock()
+ atpDataSource = mock()
+ userDataSource = mock()
+ userLoginSource = mock()
+ prefs = mock()
+ registry = QuestTypeRegistry(listOf(
+ 0 to MockQuestType
+ ))
+
+ listener = mock()
+
+ on(userLoginSource.addListener(any())).then { invocation ->
+ userLoginListener = invocation.getArgument(0)
+ Unit
+ }
+
+ on(atpDataSource.addListener(any())).then { invocation ->
+ atpUpdatesListener = invocation.getArgument(0)
+ Unit
+ }
+
+ on(mapDataSource.addListener(any())).then { invocation ->
+ mapDataListener = invocation.getArgument(0)
+ Unit
+ }
+
+ ctrl = AtpQuestController(mapDataSource, atpDataSource, registry)
+ ctrl.addListener(listener)
+ }
+
+ @Test
+ fun `get missing returns null`() {
+ on(atpDataSource.get(1)).thenReturn(null)
+ assertNull(ctrl.get(1))
+ }
+
+ @Test
+ fun `getAll created quests if map data is empty and does not obstruct it`() {
+ val bbox = bbox()
+ val location = LatLon(1.0, 1.0)
+ val atpEntries = listOf(
+ atpEntry(1, location, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP),
+ atpEntry(2, location, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP),
+ atpEntry(3, location, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP),
+ )
+
+ on(atpDataSource.getAll(bbox)).thenReturn(atpEntries)
+
+ val expectedQuests = atpEntries.map {
+ CreateElementUsingAtpQuest(
+ it.id, it,
+ MockQuestType,
+ location
+ )
+ }
+
+ val mapData = mock()
+ val elementList = listOf()
+ on(mapData.iterator()).thenReturn(elementList.iterator())
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(mapData)
+
+ assertEquals(
+ expectedQuests,
+ ctrl.getAllInBBox(bbox)
+ )
+ }
+
+ @Test
+ fun `getAllInBBox skips quest blocked by nearby matching items, does not far away or mismatching ones`() {
+ val bbox = bbox()
+ val locationForAtpAndMatchingOsmItem = LatLon(11.0, 12.0)
+ val locationForAtpAndNotMatchingOsmItem = LatLon(1.0, 1.0)
+ val farAwayLocationForOsmItem = LatLon(2.0, 2.0)
+ val farAwayLocationFromMatchingOsmItem = LatLon(2.0, 3.0)
+ val tagsForAtpAndMatchingOsm = mapOf("name" to "\$NAME", "shop" to "convenience")
+ val tagsForAtpNotMatchingOsm = mapOf("name" to "Gęśla Jaźń", "shop" to "bakery")
+ val tagsForOsmNotMatchingAtp = mapOf("name" to "Gęśla Jaźń", "shop" to "car_parts")
+ val atpEntries = listOf(
+ atpEntry(1, locationForAtpAndMatchingOsmItem, tagsInATP=tagsForAtpAndMatchingOsm, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP),
+ atpEntry(2, locationForAtpAndNotMatchingOsmItem, tagsInATP=tagsForAtpNotMatchingOsm, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP),
+ atpEntry(3, farAwayLocationFromMatchingOsmItem, tagsInATP=tagsForAtpAndMatchingOsm, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP)
+ )
+
+ on(atpDataSource.getAll(bbox)).thenReturn(atpEntries)
+
+ val expectedQuests = listOf(
+ CreateElementUsingAtpQuest(
+ id = atpEntries[1].id,
+ atpEntry = atpEntries[1],
+ type = MockQuestType,
+ position = atpEntries[1].position,
+ ),
+ CreateElementUsingAtpQuest(
+ id = atpEntries[2].id,
+ atpEntry = atpEntries[2],
+ type = MockQuestType,
+ position = atpEntries[2].position,
+ ),
+ )
+
+ val mapData = mock()
+ val elementMatchingAtp = node(100, locationForAtpAndMatchingOsmItem, tags = tagsForAtpAndMatchingOsm)
+ val elementMatchingAtpButAway = node(101, farAwayLocationForOsmItem, tags = tagsForAtpAndMatchingOsm)
+ val elementNotMatchingAtp = node(102, locationForAtpAndMatchingOsmItem, tags = tagsForOsmNotMatchingAtp)
+ val elementList = listOf(elementMatchingAtp, elementMatchingAtpButAway, elementNotMatchingAtp)
+ on(mapData.iterator()).thenReturn(elementList.iterator())
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(mapData)
+ on(mapDataSource.getGeometry(elementMatchingAtp.type, elementMatchingAtp.id))
+ .thenReturn(ElementPointGeometry(elementMatchingAtp.position))
+ on(mapDataSource.getGeometry(elementMatchingAtpButAway.type, elementMatchingAtpButAway.id))
+ .thenReturn(ElementPointGeometry(elementMatchingAtpButAway.position))
+ on(mapDataSource.getGeometry(elementNotMatchingAtp.type, elementNotMatchingAtp.id))
+ .thenReturn(ElementPointGeometry(elementMatchingAtpButAway.position))
+
+ assertEquals(
+ expectedQuests,
+ ctrl.getAllInBBox(bbox)
+ )
+ }
+
+ // is onCleared still needed? it got copied from notes test and interface TODO LATER
+ @Test
+ fun `calls onInvalidated when cleared entries`() {
+ //atpUpdatesListener.onCleared()
+ //verify(listener).onInvalidated()
+ }
+
+ @Test
+ fun `isThereOsmAtpMatch matches on exact copies`() {
+ on(mapDataSource.getGeometry(ElementType.NODE, 1)).then {
+ val returned = mock()
+ on(returned.center).thenReturn(LatLon(0.0, 0.0))
+ returned
+ }
+ assertTrue(
+ ctrl.isThereOsmAtpMatch(mapOf("name" to "Aldi", "shop" to "supermarket"), mapOf("name" to "Aldi", "shop" to "supermarket"),
+ ElementKey(ElementType.NODE, 1),
+ LatLon(0.0, 0.0)
+ )
+ )
+ }
+
+ @Test
+ fun `isThereOsmAtpMatch matches on rejected due to large distance`() {
+ on(mapDataSource.getGeometry(ElementType.NODE, 1)).then {
+ val returned = mock()
+ on(returned.center).thenReturn(LatLon(0.0, 0.0))
+ returned
+ }
+ assertFalse(
+ ctrl.isThereOsmAtpMatch(mapOf("name" to "Aldi", "shop" to "supermarket"), mapOf("name" to "Aldi", "shop" to "supermarket"),
+ ElementKey(ElementType.NODE, 1),
+ LatLon(1.0, 0.0)
+ )
+ )
+ }
+
+ @Test
+ fun `isThereOsmAtpMatch matches despite capitalization difference`() {
+ on(mapDataSource.getGeometry(ElementType.NODE, 1)).then {
+ val returned = mock()
+ on(returned.center).thenReturn(LatLon(0.0, 0.0))
+ returned
+ }
+ assertTrue(
+ ctrl.isThereOsmAtpMatch(mapOf("name" to "ALDI"), mapOf("name" to "Aldi"),
+ ElementKey(ElementType.NODE, 1),
+ LatLon(0.0, 0.0)
+ )
+ )
+ }
+
+ @Test
+ fun `isThereOsmAtpMatch rejects matches when nothing matches`() {
+ on(mapDataSource.getGeometry(ElementType.NODE, 1)).then {
+ val returned = mock()
+ on(returned.center).thenReturn(LatLon(0.0, 0.0))
+ returned
+ }
+ assertFalse(
+ ctrl.isThereOsmAtpMatch(
+ mapOf("name" to "Foobar", "shop" to "convenience"),
+ mapOf("name" to "Platypus", "shop" to "trade"),
+ ElementKey(ElementType.NODE, 1),
+ LatLon(0.0, 0.0)
+ )
+ )
+ }
+
+ @Test
+ fun `isThereOsmAtpMatch allows matches between similar shop types`() {
+ on(mapDataSource.getGeometry(ElementType.NODE, 1)).then {
+ val returned = mock()
+ on(returned.center).thenReturn(LatLon(0.0, 0.0))
+ returned
+ }
+ assertTrue(
+ ctrl.isThereOsmAtpMatch(
+ mapOf("name" to "Tesco", "shop" to "convenience"),
+ mapOf("name" to "Tesco", "shop" to "supermarket"),
+ ElementKey(ElementType.NODE, 1),
+ LatLon(0.0, 0.0)
+ )
+ )
+ }
+
+ @Test
+ fun `new AllThePlaces entries cause quest creation`() {
+ val elementList = listOf()
+ val emptyMapData = mock()
+ on(emptyMapData.iterator()).thenReturn(elementList.iterator())
+
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(emptyMapData)
+
+ val entry = atpEntry(reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP)
+ val added = listOf(entry)
+ val deleted = listOf()
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+ val expectedQuests = listOf(CreateElementUsingAtpQuest(entry.id, entry,
+ MockQuestType, entry.position))
+ val expectedDeletedIds = listOf()
+ verify(listener).onUpdated(expectedQuests, expectedDeletedIds)
+ }
+
+
+ fun dataSetupForAtpEntryFetchedAmongExistingData(atpPos: LatLon, osmPos: LatLon, atpTags: Map, osmTags: Map, atpEntryId: Long = 10L): AtpEntry {
+ val element = node(1, osmPos, tags = osmTags)
+ val elementList = listOf(element)
+ val mapData = mock()
+ on(mapData.iterator()).thenReturn(elementList.iterator())
+
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(mapData)
+ on(mapDataSource.getGeometry(element.type, element.id)).thenReturn(ElementPointGeometry(osmPos))
+
+ val entry = atpEntry(position = atpPos, tagsInATP = atpTags, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP)
+ return entry
+ }
+
+ @Test
+ fun `new AllThePlaces entries with matching shop already results in no quest`() {
+ val pos = LatLon(20.0, 40.0)
+ val osmTags = mapOf("shop" to "supermarket")
+ val atpTags = mapOf("shop" to "supermarket")
+
+ val entry = dataSetupForAtpEntryFetchedAmongExistingData(pos, pos, atpTags, osmTags)
+
+ val added = listOf(entry)
+ val deleted = listOf()
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+ verify(listener, never()).onUpdated(anyList(), anyList())
+ }
+
+
+ @Test
+ fun `new AllThePlaces entries with already present matching nearby items get no quest`() {
+ val pos = LatLon(20.0, 40.0)
+ val osmTags = mapOf("shop" to "supermarket")
+ val atpTags = mapOf("shop" to "supermarket")
+
+ val entry = dataSetupForAtpEntryFetchedAmongExistingData(pos, pos, atpTags, osmTags)
+
+ val added = listOf(entry)
+ val deleted = listOf()
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+
+ verify(listener, never()).onUpdated(anyList(), anyList())
+ }
+
+ @Test
+ fun `new AllThePlaces entry with already present matching far-away item gets quest`() {
+ val osmPos = LatLon(20.0, 40.0)
+ val atpPos = LatLon(20.0, 41.0)
+ val osmTags = mapOf("shop" to "supermarket")
+ val atpTags = mapOf("shop" to "supermarket")
+
+ val entry = dataSetupForAtpEntryFetchedAmongExistingData(osmPos, atpPos, atpTags, osmTags)
+
+ val added = listOf(entry)
+ val deleted = listOf()
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+ val expectedQuests = listOf(CreateElementUsingAtpQuest(entry.id, entry,
+ MockQuestType, entry.position))
+ val expectedDeletedIds = listOf()
+
+ verify(listener).onUpdated(expectedQuests, expectedDeletedIds)
+ }
+
+
+ @Test
+ fun `new AllThePlaces entry arriving by update with mismatching nearby items creates quest`() {
+ val pos = LatLon(20.0, 40.0)
+ val osmTags = mapOf("shop" to "supermarket")
+ val atpTags = mapOf("shop" to "hairdresser")
+
+ val entry = dataSetupForAtpEntryFetchedAmongExistingData(pos, pos, atpTags, osmTags)
+
+ val added = listOf(entry)
+ val deleted = listOf()
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+ val expectedQuests = listOf(CreateElementUsingAtpQuest(entry.id, entry,
+ MockQuestType, entry.position))
+ val expectedDeletedIds = listOf()
+
+ verify(listener).onUpdated(expectedQuests, expectedDeletedIds)
+ }
+
+ @Test
+ fun `deleted AllThePlaces entry causes quest to be marked for deletion`() {
+ val added = listOf()
+ val deleted = listOf(10L)
+ atpUpdatesListener.onUpdatedAtpElements(added, deleted)
+ val expectedQuests = listOf()
+ val expectedDeletedIds = listOf(10L)
+ verify(listener).onUpdated(expectedQuests, expectedDeletedIds)
+ }
+
+ @Test
+ fun `newly mapped POI near ATP quest causes it to disappear as it matches`() {
+ val pos = LatLon(0.0, 10.0)
+ val geom = pGeom(pos.latitude, pos.longitude)
+ val tags = mapOf("shop" to "foobar")
+ val geometryElement = ElementGeometryEntry(NODE, 1L, geom)
+ val geometries = listOf(
+ geometryElement
+ )
+ val elements = listOf(
+ node(geometryElement.elementId, tags = tags),
+ )
+ val mapData = MutableMapDataWithGeometry(elements, geometries)
+
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(mapData)
+ on(mapDataSource.getGeometry(geometryElement.elementType, geometryElement.elementId)).thenReturn(ElementPointGeometry(pos))
+
+ val entry = atpEntry(position = pos, tagsInATP = tags, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP)
+ on(atpDataSource.getAll(any())).thenReturn(listOf(entry))
+
+ mapDataListener.onUpdated(mapData, emptyList())
+
+ verify(listener).onUpdated(emptyList(), listOf(entry.id))
+ }
+
+
+ fun dataSetupForOnReplacedForBBox(atpPos: LatLon, osmPos: LatLon, atpTags: Map, osmTags: Map, atpEntryId: Long): MutableMapDataWithGeometry {
+ val entry = atpEntry(atpEntryId, position = atpPos, tagsInATP = atpTags, reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP)
+ on(atpDataSource.getAll(any())).thenReturn(listOf(entry))
+
+ val geom = pGeom(osmPos.latitude, osmPos.longitude)
+ val geometryElement = ElementGeometryEntry(NODE, atpEntryId, geom)
+ val geometries = listOf(
+ geometryElement
+ )
+ val elements = listOf(
+ node(geometryElement.elementId, tags = osmTags),
+ )
+ val mapData = MutableMapDataWithGeometry(elements, geometries)
+
+ on(mapDataSource.getMapDataWithGeometry(any())).thenReturn(mapData)
+ on(mapDataSource.getGeometry(geometryElement.elementType, geometryElement.elementId)).thenReturn(ElementPointGeometry(osmPos))
+ return mapData
+ }
+
+ @Test
+ fun `onReplacedForBBox has matching data near existing ATP quest and causes it to disappear`() {
+ val pos = LatLon(0.0, 10.0)
+ val atpTags = mapOf("shop" to "foobar")
+ val osmTags = mapOf("shop" to "foobar")
+ val atpEntryId = 10L
+
+ val mapData = dataSetupForOnReplacedForBBox(pos, pos, atpTags, osmTags, atpEntryId)
+ val bbox = pos.enclosingBoundingBox(300.0)
+ mapDataListener.onReplacedForBBox(bbox, mapData)
+
+ verify(listener).onUpdated(emptyList(), listOf(atpEntryId))
+ }
+
+ @Test
+ fun `onReplacedForBBox has not matching data near existing ATP quest and does not cause it to disappear`() {
+ val pos = LatLon(0.0, 10.0)
+ val atpTags = mapOf("shop" to "foobar")
+ val osmTags = mapOf("shop" to "some_other_value")
+ val atpEntryId = 10L
+
+ val mapData = dataSetupForOnReplacedForBBox(pos, pos, atpTags, osmTags, atpEntryId)
+ val bbox = pos.enclosingBoundingBox(300.0)
+ mapDataListener.onReplacedForBBox(bbox, mapData)
+
+ verify(listener, never()).onUpdated(anyList(), anyList())
+ }
+
+ @Test
+ fun `onReplacedForBBox has matching data far away from existing ATP quest and is not causing it to disappear`() {
+ val atpPos = LatLon(0.0, 10.0)
+ val osmPos = LatLon(0.0, 40.0)
+ val atpTags = mapOf("shop" to "foobar")
+ val osmTags = mapOf("shop" to "foobar")
+ val atpEntryId = 10L
+
+ val mapData = dataSetupForOnReplacedForBBox(atpPos, osmPos, atpTags, osmTags, atpEntryId)
+ val bbox = osmPos.enclosingBoundingBox(6_000_000.0)
+ mapDataListener.onReplacedForBBox(bbox, mapData)
+
+ verify(listener, never()).onUpdated(anyList(), anyList())
+ }
+}
+
+object MockQuestType : OsmCreateElementQuestType {
+ override val icon: Int = 199
+ override val title: Int = 199
+ override val wikiLink: String? = null
+ override val achievements: List = mock()
+ override val changesetComment: String = "changeset comment from MockQuestType"
+}
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryControllerTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryControllerTest.kt
index 557097ff921..1894c9fa8cb 100644
--- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryControllerTest.kt
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryControllerTest.kt
@@ -1,16 +1,20 @@
package de.westnordost.streetcomplete.data.edithistory
+import de.westnordost.streetcomplete.data.atp.AtpEditsController
+import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpDataWithEditsSource
import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController
import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource
import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource
import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsController
import de.westnordost.streetcomplete.data.osmnotes.edits.NoteEditsSource
import de.westnordost.streetcomplete.data.osmnotes.edits.NotesWithEditsSource
+import de.westnordost.streetcomplete.data.quest.AtpQuestKey
import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenController
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource
import de.westnordost.streetcomplete.testutils.QUEST_TYPE
import de.westnordost.streetcomplete.testutils.any
+import de.westnordost.streetcomplete.testutils.atpQuestHidden
import de.westnordost.streetcomplete.testutils.edit
import de.westnordost.streetcomplete.testutils.eq
import de.westnordost.streetcomplete.testutils.mock
@@ -28,9 +32,11 @@ class EditHistoryControllerTest {
private lateinit var elementEditsController: ElementEditsController
private lateinit var noteEditsController: NoteEditsController
+ private lateinit var atpEditsController: AtpEditsController
private lateinit var hiddenQuestsController: QuestsHiddenController
private lateinit var notesSource: NotesWithEditsSource
private lateinit var mapDataSource: MapDataWithEditsSource
+ private lateinit var atpDataSource: AtpDataWithEditsSource
private lateinit var questTypeRegistry: QuestTypeRegistry
private lateinit var listener: EditHistorySource.Listener
private lateinit var ctrl: EditHistoryController
@@ -42,9 +48,11 @@ class EditHistoryControllerTest {
@BeforeTest fun setUp() {
elementEditsController = mock()
noteEditsController = mock()
+ atpEditsController = mock()
hiddenQuestsController = mock()
notesSource = mock()
mapDataSource = mock()
+ atpDataSource = mock()
questTypeRegistry = QuestTypeRegistry(listOf(
0 to QUEST_TYPE,
))
@@ -68,8 +76,8 @@ class EditHistoryControllerTest {
}
ctrl = EditHistoryController(
- elementEditsController, noteEditsController, hiddenQuestsController, notesSource,
- mapDataSource, questTypeRegistry
+ elementEditsController, noteEditsController, atpEditsController, hiddenQuestsController, notesSource,
+ mapDataSource, atpDataSource, questTypeRegistry
)
ctrl.addListener(listener)
}
@@ -129,6 +137,15 @@ class EditHistoryControllerTest {
verify(hiddenQuestsController).unhide(e.questKey)
}
+ @Test fun `undo hid atp quest`() {
+ val e = atpQuestHidden()
+ val editKey = QuestHiddenKey(questKey = e.questKey)
+ on(atpDataSource.get(e.questKey.atpEntryId)).thenReturn(e.atpEntry)
+ on(hiddenQuestsController.get(e.questKey)).thenReturn(e.createdTimestamp)
+ ctrl.undo(editKey)
+ verify(hiddenQuestsController).unhide(e.questKey)
+ }
+
@Test fun `relays added element edit`() {
val e = edit()
elementEditsListener.onAddedEdit(e)
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt
index 8532c880db0..227a38f2e40 100644
--- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSourceTest.kt
@@ -1,5 +1,7 @@
package de.westnordost.streetcomplete.data.quest
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestSource
+import de.westnordost.streetcomplete.data.atp.atpquests.CreateElementUsingAtpQuest
import de.westnordost.streetcomplete.data.download.tiles.asBoundingBoxOfEnclosingTiles
import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry
import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
@@ -9,10 +11,12 @@ import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuest
import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestSource
import de.westnordost.streetcomplete.data.overlays.Overlay
import de.westnordost.streetcomplete.data.overlays.SelectedOverlaySource
+import de.westnordost.streetcomplete.data.quest.atp.CreatePoiBasedOnAtpAnswer
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource
import de.westnordost.streetcomplete.data.visiblequests.TeamModeQuestFilter
import de.westnordost.streetcomplete.data.visiblequests.VisibleEditTypeSource
import de.westnordost.streetcomplete.testutils.any
+import de.westnordost.streetcomplete.testutils.atpQuest
import de.westnordost.streetcomplete.testutils.bbox
import de.westnordost.streetcomplete.testutils.eq
import de.westnordost.streetcomplete.testutils.mock
@@ -35,12 +39,15 @@ class VisibleQuestsSourceTest {
private lateinit var questsHiddenSource: QuestsHiddenSource
private lateinit var questTypeRegistry: QuestTypeRegistry
private lateinit var osmNoteQuestSource: OsmNoteQuestSource
+ private lateinit var atpQuestSource: AtpQuestSource
+
private lateinit var visibleEditTypeSource: VisibleEditTypeSource
private lateinit var teamModeQuestFilter: TeamModeQuestFilter
private lateinit var selectedOverlaySource: SelectedOverlaySource
private lateinit var source: VisibleQuestsSource
private lateinit var noteQuestListener: OsmNoteQuestSource.Listener
+ private lateinit var atpQuestListener: AtpQuestSource.Listener
private lateinit var questListener: OsmQuestSource.Listener
private lateinit var questsHiddenListener: QuestsHiddenSource.Listener
private lateinit var visibleEditTypeListener: VisibleEditTypeSource.Listener
@@ -56,6 +63,7 @@ class VisibleQuestsSourceTest {
@BeforeTest fun setUp() {
osmNoteQuestSource = mock()
osmQuestSource = mock()
+ atpQuestSource = mock()
questsHiddenSource = mock()
visibleEditTypeSource = mock()
teamModeQuestFilter = mock()
@@ -69,6 +77,10 @@ class VisibleQuestsSourceTest {
noteQuestListener = (invocation.arguments[0] as OsmNoteQuestSource.Listener)
Unit
}
+ on(atpQuestSource.addListener(any())).then { invocation ->
+ atpQuestListener = (invocation.arguments[0] as AtpQuestSource.Listener)
+ Unit
+ }
on(osmQuestSource.addListener(any())).then { invocation ->
questListener = (invocation.arguments[0] as OsmQuestSource.Listener)
Unit
@@ -91,7 +103,7 @@ class VisibleQuestsSourceTest {
}
source = VisibleQuestsSource(
- questTypeRegistry, osmQuestSource, osmNoteQuestSource, questsHiddenSource,
+ questTypeRegistry, osmQuestSource, osmNoteQuestSource, atpQuestSource, questsHiddenSource,
visibleEditTypeSource, teamModeQuestFilter, selectedOverlaySource
)
@@ -103,22 +115,27 @@ class VisibleQuestsSourceTest {
val bboxCacheWillRequest = bbox.asBoundingBoxOfEnclosingTiles(16)
val osmQuests = questTypes.map { OsmQuest(it, ElementType.NODE, 1L, pGeom()) }
val noteQuests = listOf(osmNoteQuest(0L, p(0.0, 0.0)), osmNoteQuest(1L, p(1.0, 1.0)))
+ val atpQuests = listOf(atpQuest(id=1L), atpQuest(id=10L), atpQuest(id=100L), atpQuest(id=1000L))
on(osmQuestSource.getAllInBBox(bboxCacheWillRequest, questTypeNames)).thenReturn(osmQuests)
on(osmNoteQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(noteQuests)
+ on(atpQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(atpQuests)
on(questsHiddenSource.get(any())).thenReturn(null)
val quests = source.getAll(bbox)
- assertEquals(5, quests.size)
assertEquals(3, quests.filterIsInstance().size)
assertEquals(2, quests.filterIsInstance().size)
+ assertEquals(4, quests.filterIsInstance().size)
+ assertEquals(9, quests.size)
}
@Test fun `getAll does not return those that are hidden by user`() {
val bboxCacheWillRequest = bbox.asBoundingBoxOfEnclosingTiles(16)
val osmQuests = questTypes.map { OsmQuest(it, ElementType.NODE, 1L, pGeom()) }
val noteQuests = listOf(osmNoteQuest(0L, p(0.0, 0.0)), osmNoteQuest(1L, p(1.0, 1.0)))
+ val atpQuests = listOf(atpQuest(id=1L), atpQuest(id=10L), atpQuest(id=100L), atpQuest(id=1000L))
on(osmQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(osmQuests)
on(osmNoteQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(noteQuests)
+ on(atpQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(atpQuests)
on(questsHiddenSource.get(any())).thenReturn(1)
@@ -130,8 +147,10 @@ class VisibleQuestsSourceTest {
val bboxCacheWillRequest = bbox.asBoundingBoxOfEnclosingTiles(16)
val osmQuest = OsmQuest(questTypes.first(), ElementType.NODE, 1L, pGeom())
val noteQuest = osmNoteQuest(0L, p(0.0, 0.0))
+ val atpQuests = listOf(atpQuest(id=1L), atpQuest(id=10L), atpQuest(id=100L), atpQuest(id=1000L))
on(osmQuestSource.getAllInBBox(bboxCacheWillRequest, questTypeNames)).thenReturn(listOf(osmQuest))
on(osmNoteQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(listOf(noteQuest))
+ on(atpQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(atpQuests)
on(questsHiddenSource.get(any())).thenReturn(null)
on(teamModeQuestFilter.isVisible(any())).thenReturn(false)
on(teamModeQuestFilter.isEnabled).thenReturn(true)
@@ -145,6 +164,7 @@ class VisibleQuestsSourceTest {
on(osmQuestSource.getAllInBBox(bboxCacheWillRequest, listOf("TestQuestTypeA")))
.thenReturn(listOf(OsmQuest(TestQuestTypeA(), ElementType.NODE, 1, ElementPointGeometry(bbox.min))))
on(osmNoteQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(listOf())
+ on(atpQuestSource.getAllInBBox(bboxCacheWillRequest)).thenReturn(listOf())
on(questsHiddenSource.get(any())).thenReturn(null)
val overlay: Overlay = mock()
@@ -191,6 +211,24 @@ class VisibleQuestsSourceTest {
verifyNoInteractions(listener)
}
+ @Test fun `atp quests added or removed triggers listener`() {
+ val quests = listOf(atpQuest(1L), atpQuest(2L))
+ val deleted = listOf(AtpQuestKey(3), AtpQuestKey(4))
+ on(questsHiddenSource.get(any())).thenReturn(null)
+
+ atpQuestListener.onUpdated(quests, listOf(3L, 4L))
+ verify(listener).onUpdated(eq(quests), eq(deleted))
+ }
+
+ @Test fun `atp quests added of invisible type does not trigger listener`() {
+ val quests = listOf(atpQuest(1L), atpQuest(2L))
+ on(visibleEditTypeSource.isVisible(any())).thenReturn(false)
+ on(questsHiddenSource.get(any())).thenReturn(null)
+
+ atpQuestListener.onUpdated(quests, emptyList())
+ verifyNoInteractions(listener)
+ }
+
@Test fun `trigger invalidate listener if quest type visibilities changed`() {
visibleEditTypeListener.onVisibilitiesChanged()
verify(listener).onInvalidated()
@@ -201,6 +239,11 @@ class VisibleQuestsSourceTest {
verify(listener).onInvalidated()
}
+ @Test fun `trigger invalidate listener if visible atp quests were invalidated`() {
+ atpQuestListener.onInvalidated()
+ verify(listener).onInvalidated()
+ }
+
@Test fun `trigger invalidate when all quests have been unhid`() {
questsHiddenListener.onUnhidAll()
verify(listener).onInvalidated()
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenControllerTest.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenControllerTest.kt
index 757c15d614b..c6e710a52c4 100644
--- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenControllerTest.kt
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenControllerTest.kt
@@ -1,10 +1,14 @@
package de.westnordost.streetcomplete.data.visiblequests
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHiddenAt
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestsHiddenDao
import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHiddenAt
import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestsHiddenDao
import de.westnordost.streetcomplete.data.osmnotes.notequests.NoteQuestHiddenAt
import de.westnordost.streetcomplete.data.osmnotes.notequests.NoteQuestsHiddenDao
+import de.westnordost.streetcomplete.data.quest.AtpQuestKey
import de.westnordost.streetcomplete.data.quest.OsmNoteQuestKey
+import de.westnordost.streetcomplete.data.quest.QuestKey
import de.westnordost.streetcomplete.testutils.mock
import de.westnordost.streetcomplete.testutils.on
import de.westnordost.streetcomplete.testutils.osmQuestKey
@@ -21,6 +25,7 @@ class QuestsHiddenControllerTest {
private lateinit var osmDb: OsmQuestsHiddenDao
private lateinit var notesDb: NoteQuestsHiddenDao
+ private lateinit var atpDb: AtpQuestsHiddenDao
private lateinit var ctrl: QuestsHiddenController
@@ -29,8 +34,9 @@ class QuestsHiddenControllerTest {
@BeforeTest fun setUp() {
osmDb = mock()
notesDb = mock()
+ atpDb = mock()
listener = mock()
- ctrl = QuestsHiddenController(osmDb, notesDb)
+ ctrl = QuestsHiddenController(osmDb, notesDb, atpDb)
ctrl.addListener(listener)
}
@@ -39,14 +45,19 @@ class QuestsHiddenControllerTest {
val q2 = osmQuestKey(elementId = 2)
val q3 = OsmNoteQuestKey(3)
val q4 = OsmNoteQuestKey(4)
+ val q5 = AtpQuestKey(5)
+ val q6 = AtpQuestKey(6)
on(osmDb.getAll()).thenReturn(listOf(OsmQuestHiddenAt(q1, 123L)))
on(notesDb.getAll()).thenReturn(listOf(NoteQuestHiddenAt(q3.noteId, 124L)))
on(notesDb.getTimestamp(q4.noteId)).thenReturn(null)
+ on(atpDb.getAll()).thenReturn(listOf(AtpQuestHiddenAt(q5.atpEntryId, 125L)))
assertEquals(ctrl.get(q1), 123L)
assertNull(ctrl.get(q2))
assertEquals(ctrl.get(q3), 124L)
assertNull(ctrl.get(q4))
+ assertEquals(ctrl.get(q5), 125L)
+ assertNull(ctrl.get(q6))
}
@Test fun getAllNewerThan() {
@@ -54,12 +65,16 @@ class QuestsHiddenControllerTest {
val h2 = OsmQuestHiddenAt(osmQuestKey(elementId = 2), 123)
val h3 = NoteQuestHiddenAt(2L, 500)
val h4 = NoteQuestHiddenAt(3L, 123)
+ val h5 = AtpQuestHiddenAt(4L, 23)
+ val h6 = AtpQuestHiddenAt(5L, 100000)
on(osmDb.getAll()).thenReturn(listOf(h1, h2))
on(notesDb.getAll()).thenReturn(listOf(h3, h4))
+ on(atpDb.getAll()).thenReturn(listOf(h5, h6))
assertEquals(
listOf(
+ AtpQuestKey(h6.allThePlacesEntryId) to 100000L,
OsmNoteQuestKey(h3.noteId) to 500L,
h1.key to 250L,
),
@@ -70,10 +85,12 @@ class QuestsHiddenControllerTest {
@Test fun countAll() {
val h1 = OsmQuestHiddenAt(osmQuestKey(elementId = 1), 1)
val h2 = NoteQuestHiddenAt(1L, 1)
+ val h3 = AtpQuestHiddenAt(1L, 1)
on(osmDb.getAll()).thenReturn(listOf(h1))
on(notesDb.getAll()).thenReturn(listOf(h2))
- assertEquals(2, ctrl.countAll())
+ on(atpDb.getAll()).thenReturn(listOf(h3))
+ assertEquals(3, ctrl.countAll())
}
@Test fun `hide osm quest`() {
@@ -96,6 +113,16 @@ class QuestsHiddenControllerTest {
verify(listener).onHid(q, 123)
}
+ @Test fun `hide AllThePlaces quest`() {
+ val q = AtpQuestKey(1)
+ on(atpDb.getTimestamp(q.atpEntryId)).thenReturn(123L)
+
+ ctrl.hide(q)
+
+ verify(atpDb).add(q.atpEntryId)
+ verify(listener).onHid(q, 123)
+ }
+
@Test fun `unhide osm quest`() {
val q = osmQuestKey()
on(osmDb.delete(q)).thenReturn(true).thenReturn(false)
@@ -123,14 +150,30 @@ class QuestsHiddenControllerTest {
verify(listener, times(1)).onUnhid(q, 123)
}
+ @Test fun `unhide AllThePlaces quest`() {
+ val q = AtpQuestKey(2)
+
+ on(atpDb.delete(q.atpEntryId)).thenReturn(true).thenReturn(false)
+ on(atpDb.getTimestamp(q.atpEntryId)).thenReturn(123).thenReturn(null)
+
+ assertTrue(ctrl.unhide(q))
+ assertFalse(ctrl.unhide(q))
+
+ verify(atpDb, times(2)).getTimestamp(q.atpEntryId)
+ verify(atpDb, times(1)).delete(q.atpEntryId)
+ verify(listener, times(1)).onUnhid(q, 123)
+ }
+
@Test fun unhideAll() {
on(osmDb.deleteAll()).thenReturn(7)
on(notesDb.deleteAll()).thenReturn(9)
+ on(atpDb.deleteAll()).thenReturn(100)
- assertEquals(7 + 9, ctrl.unhideAll())
+ assertEquals(7 + 9 + 100, ctrl.unhideAll())
verify(osmDb).deleteAll()
verify(notesDb).deleteAll()
+ verify(atpDb).deleteAll()
verify(listener).onUnhidAll()
}
}
diff --git a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts2.kt b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts2.kt
index 0ac27f048f4..9fa4104f07c 100644
--- a/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts2.kt
+++ b/app/src/androidUnitTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts2.kt
@@ -1,5 +1,8 @@
package de.westnordost.streetcomplete.testutils
+import de.westnordost.streetcomplete.data.atp.AtpEntry
+import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHidden
+import de.westnordost.streetcomplete.data.atp.atpquests.CreateElementUsingAtpQuest
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
@@ -9,8 +12,11 @@ import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestHidden
import de.westnordost.streetcomplete.data.osmnotes.Note
import de.westnordost.streetcomplete.data.osmnotes.notequests.OsmNoteQuestHidden
import de.westnordost.streetcomplete.data.osmnotes.notequests.createOsmNoteQuest
+import de.westnordost.streetcomplete.data.quest.OsmCreateElementQuestType
import de.westnordost.streetcomplete.data.quest.OsmQuestKey
import de.westnordost.streetcomplete.data.quest.TestQuestTypeA
+import de.westnordost.streetcomplete.data.quest.atp.CreatePoiBasedOnAtpAnswer
+import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement
fun questHidden(
elementType: ElementType = ElementType.NODE,
@@ -38,10 +44,29 @@ fun osmNoteQuest(
pos: LatLon = p()
) = createOsmNoteQuest(id, pos)
+fun atpQuest(
+ id: Long = 1L,
+ atpEntry: AtpEntry = atpEntry(),
+ pos: LatLon = p(),
+) = CreateElementUsingAtpQuest(id, atpEntry, MockAtpQuestType, pos)
+
fun osmQuestKey(
elementType: ElementType = ElementType.NODE,
elementId: Long = 1L,
questTypeName: String = QUEST_TYPE.name
) = OsmQuestKey(elementType, elementId, questTypeName)
+fun atpQuestHidden(
+ atpEntry: AtpEntry = atpEntry(),
+ timestamp: Long = 123L
+) = AtpQuestHidden(atpEntry, timestamp)
+
val QUEST_TYPE = TestQuestTypeA()
+
+object MockAtpQuestType : OsmCreateElementQuestType {
+ override val icon: Int = 199
+ override val title: Int = 199
+ override val wikiLink: String? = null
+ override val achievements: List = mock()
+ override val changesetComment: String = "changeset comment from MockQuestType"
+}
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt
index 7c6fcd91917..881b137e718 100644
--- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/ApplicationConstants.kt
@@ -73,6 +73,11 @@ object ApplicationConstants {
// e.g. AddRecyclingContainerMaterials, AddCycleway
const val QUEST_FILTER_PADDING = 20.0 // m
+ /** ATP quest gets a larger padding
+ * In some cases ATP entries are near OSM objects, but offset is larger than QUEST_FILTER_PADDING
+ * Note that download still uses QUEST_FILTER_PADDING, that should be fine overall
+ */
+ const val ATP_QUEST_FILTER_PADDING = 50.0 // m
const val AVATARS_CACHE_DIRECTORY = "osm_user_avatars"
const val SC_PHOTO_SERVICE_URL = "https://streetcomplete.app/photo-upload/" // must have trailing /
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/Cleaner.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/Cleaner.kt
index 77b1249d271..458d8cc7d38 100644
--- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/Cleaner.kt
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/Cleaner.kt
@@ -1,6 +1,7 @@
package de.westnordost.streetcomplete.data
import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.data.atp.AtpController
import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesController
import de.westnordost.streetcomplete.data.logs.LogsController
import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader
@@ -21,6 +22,7 @@ import kotlinx.coroutines.launch
class Cleaner(
private val noteController: NoteController,
private val mapDataController: MapDataController,
+ private val atpController: AtpController,
private val questTypeRegistry: QuestTypeRegistry,
private val downloadedTilesController: DownloadedTilesController,
private val logsController: LogsController,
@@ -34,6 +36,7 @@ class Cleaner(
val oldDataTimestamp = nowAsEpochMilliseconds() - ApplicationConstants.DELETE_OLD_DATA_AFTER
noteController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS)
mapDataController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS)
+ atpController.deleteOlderThan(oldDataTimestamp, MAX_DELETE_ELEMENTS)
downloadedTilesController.deleteOlderThan(oldDataTimestamp)
// do this after cleaning map data and notes, because some metadata rely on map data
questTypeRegistry.forEach { it.deleteMetadataOlderThan(oldDataTimestamp) }
@@ -48,6 +51,7 @@ class Cleaner(
mapTilesDownloader.clear()
downloadedTilesController.clear()
mapDataController.clear()
+ atpController.clear()
noteController.clear()
logsController.clear()
questTypeRegistry.forEach { it.deleteMetadataOlderThan(nowAsEpochMilliseconds()) }
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
index 6f30490005c..a9c715bdd6f 100644
--- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/DatabaseInitializer.kt
@@ -1,5 +1,7 @@
package de.westnordost.streetcomplete.data
+import de.westnordost.streetcomplete.data.atp.AtpQuestsHiddenTable
+import de.westnordost.streetcomplete.data.atp.AtpTable
import de.westnordost.streetcomplete.data.download.tiles.DownloadedTilesTable
import de.westnordost.streetcomplete.data.logs.LogsTable
import de.westnordost.streetcomplete.data.osm.created_elements.CreatedElementsTable
@@ -29,7 +31,7 @@ import de.westnordost.streetcomplete.util.logs.Log
/** Creates the database and upgrades it */
object DatabaseInitializer {
- const val DB_VERSION = 19
+ const val DB_VERSION = 20
fun onCreate(db: Database) {
// OSM notes
@@ -99,6 +101,12 @@ object DatabaseInitializer {
// logs
db.exec(LogsTable.CREATE)
db.exec(LogsTable.INDEX_CREATE)
+
+ // ATP data
+ db.exec(AtpTable.CREATE)
+ db.exec(AtpTable.INDEX_CREATE)
+ db.exec(AtpTable.SPATIAL_INDEX_CREATE)
+ db.exec(AtpQuestsHiddenTable.CREATE)
}
fun onUpgrade(db: Database, oldVersion: Int, newVersion: Int) {
@@ -254,6 +262,12 @@ object DatabaseInitializer {
db.deleteQuest("AddParcelLockerPickup")
db.deleteQuest("AddShoulder")
}
+ if (oldVersion <= 19 && newVersion > 19) {
+ db.exec(AtpTable.CREATE)
+ db.exec(AtpTable.INDEX_CREATE)
+ db.exec(AtpTable.SPATIAL_INDEX_CREATE)
+ db.exec(AtpQuestsHiddenTable.CREATE)
+ }
}
}
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/OsmApiModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/OsmApiModule.kt
index a553173fdc0..212e727c7d1 100644
--- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/OsmApiModule.kt
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/OsmApiModule.kt
@@ -22,7 +22,7 @@ val OSM_API_URL =
if (USE_TEST_API) OSM_API_URL_TEST else OSM_API_URL_LIVE
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/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClient.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClient.kt
new file mode 100644
index 00000000000..84e34cc8f79
--- /dev/null
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiClient.kt
@@ -0,0 +1,52 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.ConnectionException
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.wrapApiClientExceptions
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.expectSuccess
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.utils.io.asSource
+import kotlinx.io.buffered
+import kotlin.math.floor
+
+/**
+ * Communicates with separate API providing ATP entries.
+ * TODO: also sends info when entries were wonky
+ */
+class AtpApiClient(
+ private val httpClient: HttpClient,
+ private val baseUrl: String,
+ //private val userAccessTokenSource: UserAccessTokenSource,
+ private val atpApiParser: AtpApiParser
+) {
+ /**
+ * Retrieve all atp entries in the given area
+ *
+ * @param bounds the area within where ATP entries should be queried.
+ *
+ * @throws ConnectionException if a temporary network connection problem occurs
+ * @throws IllegalArgumentException if the bounds cross the 180th meridian.
+ *
+ * @return the incoming atp entries
+ */
+ suspend fun getAllAtpEntries(bounds: BoundingBox): List = wrapApiClientExceptions {
+ if (bounds.crosses180thMeridian) {
+ throw IllegalArgumentException("Bounding box crosses 180th meridian")
+ }
+ val gathered = mutableListOf()
+ // example: https://bbox-filter-for-atp.bulwersator-cloudflare.workers.dev/api/entries?lat_min=50&lat_max=50.05&lon_min=19.9&lon_max=20.1
+ val url = baseUrl + "entries?lat_min=${bounds.min.latitude}&lat_max=${bounds.max.latitude}&lon_min=${bounds.min.longitude}&lon_max=${bounds.max.longitude}"
+ try {
+ val response = httpClient.get(url) { expectSuccess = true }
+ val source = response.bodyAsChannel().asSource().buffered()
+ gathered += atpApiParser.parseAtpEntries(source)
+ } catch (e: ClientRequestException) {
+ throw e
+ }
+ return gathered
+ }
+
+}
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParser.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParser.kt
new file mode 100644
index 00000000000..c5d8b048f4c
--- /dev/null
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParser.kt
@@ -0,0 +1,140 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.logs.Log
+import kotlinx.io.Source
+import kotlinx.io.readString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+class AtpApiParser {
+
+ fun isParsedNull(element: JsonElement?): Boolean {
+ return element is JsonPrimitive && element?.jsonPrimitive is JsonNull
+ }
+ fun parseAtpEntries(source: Source) : List {
+ val returned = mutableListOf()
+ val features = Json.parseToJsonElement(source.readString()).jsonArray
+ features.forEach { feature ->
+ //val properties = feature.jsonObject.entries
+ val lon = feature.jsonObject["atp_center_lon"]?.toString()?.toDoubleOrNull()
+ if (lon == null) {
+ Log.e(
+ TAG,
+ "lon entry missing in OSM_ATP API comparison response, this response is malformed"
+ )
+ return@forEach
+ }
+ val lat = feature.jsonObject["atp_center_lat"]?.toString()?.toDoubleOrNull()
+ if (lat == null) {
+ Log.e(
+ TAG,
+ "lat entry missing in OSM_ATP API comparison response, this response is malformed"
+ )
+ return@forEach
+ }
+ val id = feature.jsonObject["atp_entry_id"]?.toString()?.toLongOrNull()
+ if (id == null) {
+ Log.e(
+ TAG,
+ "id entry missing in OSM_ATP API comparison response, this response is malformed"
+ )
+ return@forEach
+ }
+ val rawOsmObjectId = feature.jsonObject["osm_element_match_id"]
+ val rawOsmObjectType = feature.jsonObject["osm_element_match_type"]
+ val osmObjectType = if(isParsedNull(rawOsmObjectType)) {
+ null
+ } else {
+ rawOsmObjectType?.jsonPrimitive?.content
+ }
+ val osmObjectId = if(isParsedNull(rawOsmObjectId)) {
+ null
+ } else {
+ rawOsmObjectId?.jsonPrimitive?.content?.toLongOrNull()
+ }
+ val osmMatch = if (osmObjectType == null || osmObjectId == null) {
+ null
+ } else {
+ when (osmObjectType) {
+ "node" -> {
+ ElementKey(ElementType.NODE, osmObjectId)
+ }
+
+ "way" -> {
+ ElementKey(ElementType.WAY, osmObjectId)
+ }
+
+ "relation" -> {
+ ElementKey(ElementType.RELATION, osmObjectId)
+ }
+
+ else -> {
+ Log.e(
+ TAG,
+ "osm_object_type has invalid value OSM_ATP API comparison response, this response is malformed"
+ )
+ return@forEach
+ }
+ }
+ }
+ val unparsedAtpTags = feature.jsonObject["atp_tags"]?.jsonPrimitive?.content
+ if (unparsedAtpTags == null) {
+ Log.e(
+ TAG,
+ "tagsInATP entry missing in OSM_ATP API comparison response, this response is malformed"
+ )
+ return@forEach
+ }
+ //val tagsInATP = unparsedAtpTags.mapValues { // TODO avoid double-parsing
+ val tagsInATP = Json.parseToJsonElement(unparsedAtpTags).jsonObject.mapValues {
+ it.value.jsonPrimitive.content
+ }
+ val rawOsmTags = feature.jsonObject["osm_match_tags"]?.jsonPrimitive?.content
+ val parsedRawOsmTags = Json.parseToJsonElement(rawOsmTags!!) // TODO avoid double-parsing
+ //val tagsInOSM = if (isParsedNull(feature.jsonObject["osm_match_tags"])) { // TODO avoid double-parsing
+ val tagsInOSM = if (isParsedNull(parsedRawOsmTags)) {
+ null
+ } else {
+ //feature.jsonObject["osm_match_tags"]?.jsonObject?.mapValues { // TODO avoid double-parsing
+ parsedRawOsmTags.jsonObject.mapValues {
+ it.value.jsonPrimitive.content
+ }
+ }
+ val rawErrorValue = feature.jsonObject["report_type"]?.jsonPrimitive?.content
+ val reportType = rawErrorValue.let { errorValue ->
+ when (errorValue) {
+ "MISSING_POI_IN_OPENSTREETMAP" -> {
+ ReportType.MISSING_POI_IN_OPENSTREETMAP
+ }
+ "OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP" -> {
+ ReportType.OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP
+ }
+ else -> {
+ Log.e(TAG, "report_type has invalid value ($errorValue) OSM_ATP API comparison response, this response is malformed")
+ return@forEach
+ }
+ }
+ }
+ returned.add(AtpEntry(
+ position = LatLon(lat, lon),
+ id = id,
+ osmMatch = osmMatch,
+ tagsInATP = tagsInATP,
+ tagsInOSM = tagsInOSM,
+ reportType = reportType,
+ ))
+ }
+ return returned
+ }
+ companion object {
+ private const val TAG = "AtpApiParser"
+ }
+}
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpController.kt
new file mode 100644
index 00000000000..c78dc47f1e7
--- /dev/null
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpController.kt
@@ -0,0 +1,114 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.Listeners
+import de.westnordost.streetcomplete.util.ktx.format
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import de.westnordost.streetcomplete.util.logs.Log
+import kotlinx.atomicfu.locks.ReentrantLock
+import kotlinx.atomicfu.locks.withLock
+
+/** Manages access to the ATP data storage */
+class AtpController(
+ private val dao: AtpDao
+) {
+ /* Must be a singleton because there is a listener that should respond to a change in the
+ * database table */
+
+ /** Interface to be notified of new atp entries, updated atp entries and atp entries that have been deleted */
+ interface Listener {
+ /** called when a number of ATP entries has been added, updated or deleted */
+ fun onUpdated(added: Collection, updated: Collection, deleted: Collection)
+ /** called when all atp entries have been cleared */
+ fun onCleared()
+ }
+ private val listeners = Listeners()
+
+ private val lock = ReentrantLock()
+
+ /** Replace all entries in the given bounding box with the given entries */
+ fun putAllForBBox(bbox: BoundingBox, entries: Collection) {
+ val time = nowAsEpochMilliseconds()
+
+ val oldEntriesById = mutableMapOf()
+ val addedEntries = mutableListOf()
+ val updatedEntries = mutableListOf()
+ lock.withLock {
+ dao.getAll(bbox).associateByTo(oldEntriesById) { it.id }
+
+ for (entry in entries) {
+ if (oldEntriesById.containsKey(entry.id)) {
+ updatedEntries.add(entry)
+ } else {
+ addedEntries.add(entry)
+ }
+ oldEntriesById.remove(entry.id)
+ }
+
+ dao.putAll(entries)
+ dao.deleteAll(oldEntriesById.keys)
+ }
+
+ val seconds = (nowAsEpochMilliseconds() - time) / 1000.0
+ Log.i(TAG, "Persisted ${addedEntries.size} and deleted ${oldEntriesById.size} ATP entries in ${seconds.format(1)}s")
+
+ this@AtpController.onUpdated(added = addedEntries, updated = updatedEntries, deleted = oldEntriesById.keys)
+ }
+
+ fun get(entryId: Long): AtpEntry? = dao.get(entryId)
+
+ /** it was needed for notes (delete a note because the note does not exist anymore on OSM (has been closed)) - do we need it here TODO */
+ fun delete(entryId: Long) {
+ val deleteSuccess = synchronized(this) { dao.delete(entryId) }
+ if (deleteSuccess) {
+ this@AtpController.onUpdated(deleted = listOf(entryId))
+ }
+ }
+
+
+ fun deleteOlderThan(timestamp: Long, limit: Int? = null): Int {
+ val ids: List
+ val deletedCount: Int
+ synchronized(this) {
+ ids = dao.getIdsOlderThan(timestamp, limit)
+ if (ids.isEmpty()) return 0
+
+ deletedCount = dao.deleteAll(ids)
+ }
+
+ Log.i(TAG, "Deleted $deletedCount old atp entries")
+
+ this@AtpController.onUpdated(deleted = ids)
+
+ return ids.size
+ }
+
+ fun clear() {
+ dao.clear()
+ listeners.forEach { it.onCleared() }
+ }
+
+ fun getAllPositions(bbox: BoundingBox): List = dao.getAllPositions(bbox)
+ fun getAll(bbox: BoundingBox): List = dao.getAll(bbox)
+ fun getAll(atpEntries: Collection): List = dao.getAll(atpEntries)
+
+ /* ------------------------------------ Listeners ------------------------------------------- */
+
+ fun addListener(listener: Listener) {
+ listeners.add(listener)
+ }
+ fun removeListener(listener: Listener) {
+ listeners.remove(listener)
+ }
+
+ private fun onUpdated(added: Collection = emptyList(), updated: Collection = emptyList(), deleted: Collection = emptyList()) {
+ if (added.isEmpty() && updated.isEmpty() && deleted.isEmpty()) return
+
+ listeners.forEach { it.onUpdated(added, updated, deleted) }
+ }
+
+ companion object {
+ private const val TAG = "AtpController"
+ }
+}
diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDao.kt
new file mode 100644
index 00000000000..eee7a2c83a1
--- /dev/null
+++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDao.kt
@@ -0,0 +1,128 @@
+package de.westnordost.streetcomplete.data.atp
+
+import de.westnordost.streetcomplete.data.CursorPosition
+import de.westnordost.streetcomplete.data.Database
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.atp.AtpTable.NAME
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.ID
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.LATITUDE
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.LONGITUDE
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.OSM_ELEMENT_MATCH_ID
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.OSM_ELEMENT_MATCH_TYPE
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.ATP_TAGS
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.OSM_TAGS
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.LAST_SYNC
+import de.westnordost.streetcomplete.data.atp.AtpTable.Columns.REPORT_TYPE
+import de.westnordost.streetcomplete.data.osm.mapdata.Element
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
+import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
+import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds
+import kotlinx.serialization.json.Json
+
+/** Stores ATP entries */
+class AtpDao(private val db: Database) {
+ fun put(entry: AtpEntry) {
+ db.replace(NAME, entry.toPairs())
+ }
+
+ fun get(id: Long): AtpEntry? =
+ db.queryOne(NAME, where = "$ID = $id") { it.toAtpEntry() }
+
+ fun delete(id: Long): Boolean =
+ db.delete(NAME, "$ID = $id") == 1
+
+ fun putAll(entries: Collection) {
+ if (entries.isEmpty()) return
+
+ db.replaceMany(NAME,
+ arrayOf(ID, LATITUDE, LONGITUDE, OSM_ELEMENT_MATCH_ID, OSM_ELEMENT_MATCH_TYPE, ATP_TAGS, OSM_TAGS, REPORT_TYPE, LAST_SYNC),
+ entries.map { arrayOf(
+ it.id,
+ it.position.latitude,
+ it.position.longitude,
+ it.osmMatch?.id,
+ it.osmMatch?.type.toString(),
+ Json.encodeToString(it.tagsInATP),
+ it.tagsInOSM?.let { Json.encodeToString(it) },
+ it.reportType.name,
+ nowAsEpochMilliseconds()
+ ) }
+ )
+ }
+
+ fun getAll(bbox: BoundingBox): List =
+ db.query(NAME, where = inBoundsSql(bbox)) { it.toAtpEntry() }
+
+ fun getAllPositions(bbox: BoundingBox): List =
+ db.query(NAME,
+ columns = arrayOf(LATITUDE, LONGITUDE),
+ where = inBoundsSql(bbox),
+ ) { LatLon(it.getDouble(LATITUDE), it.getDouble(LONGITUDE)) }
+
+ fun getAll(ids: Collection): List {
+ if (ids.isEmpty()) return emptyList()
+ return db.query(NAME, where = "$ID IN (${ids.joinToString(",")})") { it.toAtpEntry() }
+ }
+
+ fun getIdsOlderThan(timestamp: Long, limit: Int? = null): List =
+ if (limit != null && limit <= 0) {
+ emptyList()
+ } else {
+ db.query(NAME,
+ columns = arrayOf(ID),
+ where = "$LAST_SYNC < $timestamp",
+ limit = limit
+ ) { it.getLong(ID) }
+ }
+
+ fun deleteAll(ids: Collection): Int {
+ if (ids.isEmpty()) return 0
+ return db.delete(NAME, "$ID IN (${ids.joinToString(",")})")
+ }
+
+ fun clear() {
+ db.delete(NAME)
+ }
+
+ private fun AtpEntry.toPairs() = listOf(
+ ID to id,
+ LATITUDE to position.latitude,
+ LONGITUDE to position.longitude,
+ OSM_ELEMENT_MATCH_ID to osmMatch?.id,
+ OSM_ELEMENT_MATCH_TYPE to osmMatch?.type?.name?.lowercase(),
+ ATP_TAGS to Json.encodeToString(tagsInATP),
+ OSM_TAGS to tagsInOSM?.let { Json.encodeToString(it) },
+ LAST_SYNC to nowAsEpochMilliseconds(),
+ REPORT_TYPE to reportType.name.lowercase(),
+ )
+
+ private fun CursorPosition.toAtpEntry(): AtpEntry {
+ val tagsInOsm = getStringOrNull(OSM_TAGS)?.let {
+ Json.decodeFromString