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 nlnet
-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
GitHub Sponsors Liberapay Patreon
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>(it) + } + val osmMatchId = getLongOrNull(OSM_ELEMENT_MATCH_ID) + val osmMatchType = getStringOrNull(OSM_ELEMENT_MATCH_TYPE) + val osmMatch = if(osmMatchId == null || osmMatchType == null) { + null + } else { + ElementKey( ElementType.valueOf(osmMatchType.uppercase()), osmMatchId) + } + val reportType = ReportType.valueOf(getString(REPORT_TYPE).uppercase()) + + val atpEntry = AtpEntry( + LatLon(getDouble(LATITUDE), getDouble(LONGITUDE)), + getLong(ID), + osmMatch, + Json.decodeFromString(getString(ATP_TAGS)), + tagsInOsm, + reportType, + ) + return atpEntry + } + + private fun inBoundsSql(bbox: BoundingBox): String = """ + ($LATITUDE BETWEEN ${bbox.min.latitude} AND ${bbox.max.latitude}) AND + ($LONGITUDE BETWEEN ${bbox.min.longitude} AND ${bbox.max.longitude}) + """.trimIndent() +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDownloader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDownloader.kt new file mode 100644 index 00000000000..091ef951f43 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpDownloader.kt @@ -0,0 +1,38 @@ +package de.westnordost.streetcomplete.data.atp + +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +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.ktx.format +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.contains +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +/** Takes care of downloading ATP data into persistent storage */ +class AtpDownloader( + private val atpApi: AtpApiClient, + private val atpController: AtpController +) { + suspend fun download(bbox: BoundingBox) { + try { + // ATP data download failing should not take down entire download + val time = nowAsEpochMilliseconds() + val entries: Collection = atpApi.getAllAtpEntries(bbox) + val seconds = (nowAsEpochMilliseconds() - time) / 1000.0 + Log.i(TAG, "Downloaded ${entries.size} ATP entries in ${seconds.format(1)}s") + yield() + withContext(Dispatchers.IO) { atpController.putAllForBBox(bbox, entries) } + } catch (e: Exception) { + Log.w(TAG, e.message.orEmpty(), e) + } + } + + companion object { + private const val TAG = "AtpDownload" + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsController.kt new file mode 100644 index 00000000000..a8d0d94da7d --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsController.kt @@ -0,0 +1,9 @@ +package de.westnordost.streetcomplete.data.atp + +import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpEditsSource + +class AtpEditsController ( + // private val editsDB: AtpEditsDao TODO start recording ATP edits performed (entries marked as bogus) +) : AtpEditsSource { + // TODO see NoteEditsController +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsModule.kt new file mode 100644 index 00000000000..a2c9f825d62 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEditsModule.kt @@ -0,0 +1,11 @@ +package de.westnordost.streetcomplete.data.atp + +import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpDataWithEditsSource +import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpEditsSource +import org.koin.dsl.module + +val atpEditsModule = module { + single { AtpDataWithEditsSource(get(), get(), get()) } + single { AtpEditsController() } + single { get() } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEntry.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEntry.kt new file mode 100644 index 00000000000..3b61b9e464a --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpEntry.kt @@ -0,0 +1,23 @@ +package de.westnordost.streetcomplete.data.atp + +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import kotlinx.serialization.Serializable + +@Serializable +data class AtpEntry( + // should we have separate Dao, Downloader, Controller etc for cases + // where it represents a new OSM Element and cases where it represents + // mismatching opening hours data? + val position: LatLon, + val id: Long, + val osmMatch: ElementKey?, + val tagsInATP: Map, + val tagsInOSM: Map?, + val reportType: ReportType, +) + +enum class ReportType { + MISSING_POI_IN_OPENSTREETMAP, + OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP, +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpModule.kt new file mode 100644 index 00000000000..10c1e26f408 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpModule.kt @@ -0,0 +1,15 @@ +package de.westnordost.streetcomplete.data.atp + +import org.koin.dsl.module + +const val OSM_ATP_COMPARISON_API_BASE_URL = "https://bbox-filter-for-atp.bulwersator-cloudflare.workers.dev/api/" + +val atpModule = module { + factory { AtpDao(get()) } + factory { AtpDownloader(get(), get()) } + // TODO API: connect to actual bidirectional API + factory { AtpApiClient(get(), OSM_ATP_COMPARISON_API_BASE_URL, get()) } + factory { AtpApiParser() } + + single { AtpController(get()) } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpTable.kt new file mode 100644 index 00000000000..45315fa9c70 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/AtpTable.kt @@ -0,0 +1,45 @@ +package de.westnordost.streetcomplete.data.atp + +import de.westnordost.streetcomplete.data.osm.mapdata.WayTables +import de.westnordost.streetcomplete.data.osm.mapdata.WayTables.NAME_NODES + +object AtpTable { + const val NAME = "atp_matches" + + object Columns { + const val ID = "atp_entry_id" + const val LATITUDE = "latitude" + const val LONGITUDE = "longitude" + const val OSM_ELEMENT_MATCH_ID = "osm_element_match_id" + const val OSM_ELEMENT_MATCH_TYPE = "osm_element_match_type" + const val ATP_TAGS = "atp_tags" + const val OSM_TAGS = "osm_tags" + const val LAST_SYNC = "last_sync" + const val REPORT_TYPE = "report_type" + } + + const val CREATE = """ + CREATE TABLE $NAME ( + ${Columns.ID} int PRIMARY KEY, + ${Columns.LATITUDE} double NOT NULL, + ${Columns.LONGITUDE} double NOT NULL, + ${Columns.OSM_ELEMENT_MATCH_ID} int, + ${Columns.OSM_ELEMENT_MATCH_TYPE} varchar(15), + ${Columns.ATP_TAGS} text NOT NULL, + ${Columns.OSM_TAGS} text, + ${Columns.LAST_SYNC} int NOT NULL, + ${Columns.REPORT_TYPE} text NOT NULL + ); + """ + + const val INDEX_CREATE = """ + CREATE INDEX atp_id_index ON $NAME (${Columns.ID}); + """ + + const val SPATIAL_INDEX_CREATE = """ + CREATE INDEX atp_spatial_index ON $NAME ( + ${Columns.LATITUDE}, + ${Columns.LONGITUDE} + ); + """ +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestController.kt new file mode 100644 index 00000000000..4eddc780067 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestController.kt @@ -0,0 +1,207 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import de.westnordost.streetcomplete.ApplicationConstants +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.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry +import de.westnordost.streetcomplete.data.quest.OsmCreateElementQuestType +import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry +import de.westnordost.streetcomplete.util.Listeners +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.distance +import de.westnordost.streetcomplete.util.math.enlargedBy + +/** Used to get visible atp quests */ +class AtpQuestController( + private val mapDataSource: MapDataWithEditsSource, + private val atpDataSource: AtpDataWithEditsSource, // TODO what exactly should be feed here? + private val questTypeRegistry: QuestTypeRegistry, +) : AtpQuestSource { + /* Must be a singleton because there is a listener that should respond to a change in the + * database table */ + + private val listeners = Listeners() + + private val allQuestTypes get() = questTypeRegistry.filterIsInstance>() + + fun isThereOsmAtpMatch(osm: Map, atp: Map, osmIdentifier: ElementKey, atpPosition: LatLon): Boolean { + fun isItWithinRange(osmIdentifier: ElementKey, atpPosition: LatLon): Boolean { + val distance = mapDataSource.getGeometry(osmIdentifier.type, osmIdentifier.id)?.distance(atpPosition) + return distance != null && distance < ApplicationConstants.ATP_QUEST_FILTER_PADDING + } + + val atpNames = listOfNotNull(atp["name"]?.lowercase(), atp["brand"]?.lowercase()) + if (atpNames.contains(osm["name"]?.lowercase()) || atpNames.contains(osm["brand"]?.lowercase())) { + return isItWithinRange(osmIdentifier, atpPosition) + } + // yes, with following any shop=convenience will block any shop=convenience + // within range + // this is extreme and making filter smarter may be an improvement + listOf("shop", "amenity", "leisure", "office", "tourism", "craft", "healthcare", "attraction").forEach { mainKey -> + if(atp[mainKey] != null && atp[mainKey] == osm[mainKey]) { + return isItWithinRange(osmIdentifier, atpPosition) + } + } + return false + } + + private val atpUpdatesListener = object : AtpDataWithEditsSource.Listener { + override fun onUpdatedAtpElements(added: Collection, deleted: Collection) { + val filtered = added.filter { atpEntry -> + // TODO is speed of this reasonable? I suspect that something more efficient is needed, profile + val paddedBounds = BoundingBox(atpEntry.position, atpEntry.position) //..enlargedBy(ApplicationConstants.QUEST_FILTER_PADDING) + mapDataSource.getMapDataWithGeometry(paddedBounds).none { osm -> + isThereOsmAtpMatch(osm.tags, atpEntry.tagsInATP, ElementKey(osm.type, osm.id), atpEntry.position) // true + } + } + val quests = createQuestsForAtpEntries(filtered) + onUpdatingQuestList(quests, deleted) + } + + override fun onInvalidated() { + // probably do the same as class OsmQuestController did? TODO LATER - maybe not needed at all? If used, add test, if not used at the end - purge + listeners.forEach { it.onInvalidated() } + } + } + + private val mapDataSourceListener = object : MapDataWithEditsSource.Listener { + // matches appear/disappear + override fun onUpdated( + updated: MapDataWithGeometry, + deleted: Collection, + ) { + val deletedQuestIds = mutableListOf() + updated.forEach { osm -> + // TODO STUCK how can I get access to existing quests here? + // do I really need to do atpDataSource.getAll() + // and then filter droppable quests + // and then pass ids to obsoleteQuestIds so they will be deleted + // this seems silly + // but it seems how note and osm element quests do things + // so maybe there is no better way? + + if (!osm.tags.isEmpty()) { // TODO maybe both incoming ATP entries and OSM entries should be filtered? Maybe require it to be either place or thing to avoid maintaining even more filters? To check only places, not every tagged node? + val geometry = mapDataSource.getGeometry(osm.type, osm.id) + if (geometry != null) { + val paddedBounds = geometry.bounds.enlargedBy( + ApplicationConstants.QUEST_FILTER_PADDING + ) + val candidates = atpDataSource.getAll(paddedBounds) + // TODO: profile it whether it is too slow + candidates.forEach { atpCandidate -> + if(isThereOsmAtpMatch(osm.tags, atpCandidate.tagsInATP, ElementKey(osm.type, osm.id), atpCandidate.position)) { + deletedQuestIds.add(atpCandidate.id) + // ATP entries already ineligible for quest will be also listed + // this is fine + } + } + } + } + } + + // in theory changing name or retagging shop may cause new quests to appear - lets not support this + // as most cases will be false positives anyway and this would be expensive to check + // instead pass emptyList() + onUpdatingQuestList(emptyList(), deletedQuestIds) + } + + override fun onReplacedForBBox( + bbox: BoundingBox, + mapDataWithGeometry: MapDataWithGeometry, + ) { + val paddedBounds = bbox.enlargedBy(ApplicationConstants.ATP_QUEST_FILTER_PADDING) + val obsoleteQuestIds = mutableListOf() + val candidates = atpDataSource.getAll(paddedBounds) + mapDataWithGeometry.forEach { osm -> + candidates.forEach { atpCandidate -> + if(isThereOsmAtpMatch(osm.tags, atpCandidate.tagsInATP, ElementKey(osm.type, osm.id), atpCandidate.position)) { + obsoleteQuestIds.add(atpCandidate.id) + // ATP entries already ineligible for quest will be also listed + // this is fine + } + } + } + // TODO maybe quests outside downloaded area should not appear until OSM data is also downloaded to hide unwanted copies? + onUpdatingQuestList(emptyList(), obsoleteQuestIds) + } + + override fun onCleared() { + listeners.forEach { it.onInvalidated() } + } + } + + init { + atpDataSource.addListener(atpUpdatesListener) + mapDataSource.addListener(mapDataSourceListener) + } + + override fun get(questId: Long): CreateElementUsingAtpQuest? = + atpDataSource.get(questId)?.let { createQuestForAtpEntry(it) } + + override fun getAllInBBox(bbox: BoundingBox): List { + val candidates = atpDataSource.getAll(bbox) + val paddedBounds = bbox.enlargedBy(ApplicationConstants.ATP_QUEST_FILTER_PADDING) + val filteredOutCandidates = mutableListOf() + mapDataSource.getMapDataWithGeometry(paddedBounds).forEach { osm -> + candidates.forEach { atpCandidate -> + if(!filteredOutCandidates.contains(atpCandidate)) { + if(isThereOsmAtpMatch(osm.tags, atpCandidate.tagsInATP, ElementKey(osm.type, osm.id), atpCandidate.position)) { + filteredOutCandidates.add(atpCandidate) + } + } + } + } + val filteredCandidates = candidates - filteredOutCandidates + return createQuestsForAtpEntries(filteredCandidates) + } + + private fun createQuestsForAtpEntries(entries: Collection): List = + entries.mapNotNull { createQuestForAtpEntry(it) } + + private fun createQuestForAtpEntry(entry: AtpEntry): CreateElementUsingAtpQuest? { + return if (entry.reportType == ReportType.MISSING_POI_IN_OPENSTREETMAP) { + // TODO STUCK allQuestTypes[0] is a hilarious hack of worst variety TODO (in other places I just assume single + // TODO STUCK maybe CreatePoiBasedOnAtp() and OsmCreateElementQuestType() should be merged? + // TODO STUCK but simple + // type = CreatePoiBasedOnAtp() + // does not work + // specifically, import de.westnordost.streetcomplete.data.quest.atp.CreatePoiBasedOnAtp + // fails as supposedly .atp. does not exist + // TODO STUCK maybe because it is stuck in Android part of source code? + CreateElementUsingAtpQuest(entry.id, entry,allQuestTypes[0], entry.position) + } else { + null + } + } + + /* ---------------------------------------- Listener ---------------------------------------- */ + + override fun addListener(listener: AtpQuestSource.Listener) { + listeners.add(listener) + } + + override fun removeListener(listener: AtpQuestSource.Listener) { + listeners.remove(listener) + } + + private fun onUpdatingQuestList( + quests: Collection, + deletedQuestIds: Collection + ) { + if (quests.isEmpty() && deletedQuestIds.isEmpty()) return + listeners.forEach { it.onUpdated(quests, deletedQuestIds) } + } + + private fun onInvalidated() { + listeners.forEach { it.onInvalidated() } + } + + companion object { + private const val TAG = "AtpQuestController" + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestHidden.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestHidden.kt new file mode 100644 index 00000000000..24c7d5c7aae --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestHidden.kt @@ -0,0 +1,18 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import de.westnordost.streetcomplete.data.atp.AtpEntry +import de.westnordost.streetcomplete.data.edithistory.Edit +import de.westnordost.streetcomplete.data.edithistory.QuestHiddenKey +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.quest.AtpQuestKey + +data class AtpQuestHidden ( + val atpEntry: AtpEntry, + override val createdTimestamp: Long +) : Edit { + val questKey get() = AtpQuestKey(atpEntry.id) + override val key: QuestHiddenKey get() = QuestHiddenKey(questKey) + override val isUndoable: Boolean get() = true + override val position: LatLon get() = atpEntry.position + override val isSynced: Boolean? get() = null +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestModule.kt new file mode 100644 index 00000000000..4bbee65c0f7 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestModule.kt @@ -0,0 +1,11 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import org.koin.dsl.module + +val atpQuestModule = module { + factory { AtpQuestsHiddenDao(get()) } + + single { get() } + + single { AtpQuestController(get(), get(), get()) } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestSource.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestSource.kt new file mode 100644 index 00000000000..caee87a8ecc --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestSource.kt @@ -0,0 +1,20 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox + +interface AtpQuestSource { + // TODO API REQUIRES API see OsmQuestSource and OsmNoteQuestSource + interface Listener { + fun onUpdated(added: Collection, deleted: Collection) + fun onInvalidated() + } + + /** get single quest by id if not hidden by user */ + fun get(questId: Long): CreateElementUsingAtpQuest? + + /** Get all quests in given bounding box */ + fun getAllInBBox(bbox: BoundingBox): List + + fun addListener(listener: Listener) + fun removeListener(listener: Listener) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDao.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDao.kt new file mode 100644 index 00000000000..ed2b7a86f2d --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenDao.kt @@ -0,0 +1,41 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import de.westnordost.streetcomplete.data.CursorPosition +import de.westnordost.streetcomplete.data.Database +import de.westnordost.streetcomplete.data.atp.AtpQuestsHiddenTable.Columns.ATP_ENTRY_ID +import de.westnordost.streetcomplete.data.atp.AtpQuestsHiddenTable.Columns.TIMESTAMP +import de.westnordost.streetcomplete.data.atp.AtpQuestsHiddenTable.NAME +import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds + +/** Persists which atp ids should be hidden (because the user selected so) in the AllThePlaces quest */ +class AtpQuestsHiddenDao(private val db: Database) { + fun add(allThePlacesEntryId: Long) { + db.insert(NAME, listOf( + ATP_ENTRY_ID to allThePlacesEntryId, + TIMESTAMP to nowAsEpochMilliseconds() + )) + } + + fun getTimestamp(allThePlacesEntryId: Long): Long? = + db.queryOne(NAME, where = "$ATP_ENTRY_ID = $allThePlacesEntryId") { it.getLong(TIMESTAMP) } + + fun delete(allThePlacesEntryId: Long): Boolean = + db.delete(NAME, where = "$ATP_ENTRY_ID = $allThePlacesEntryId") == 1 + + fun getNewerThan(timestamp: Long): List = + db.query(NAME, where = "$TIMESTAMP > $timestamp") { it.toAtpQuestHiddenAt() } + + fun getAll(): List = + db.query(NAME) { it.toAtpQuestHiddenAt() } + + fun deleteAll(): Int = + db.delete(NAME) + + fun countAll(): Int = + db.queryOne(NAME, columns = arrayOf("COUNT(*)")) { it.getInt("COUNT(*)") } ?: 0 +} + +private fun CursorPosition.toAtpQuestHiddenAt() = + AtpQuestHiddenAt(getLong(ATP_ENTRY_ID), getLong(TIMESTAMP)) + +data class AtpQuestHiddenAt(val allThePlacesEntryId: Long, val timestamp: Long) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenTable.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenTable.kt new file mode 100644 index 00000000000..2c7f3666331 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/AtpQuestsHiddenTable.kt @@ -0,0 +1,17 @@ +package de.westnordost.streetcomplete.data.atp + +object AtpQuestsHiddenTable { + const val NAME = "atp_entries_hidden" + + object Columns { + const val ATP_ENTRY_ID = "atp_id" + const val TIMESTAMP = "timestamp" + } + + const val CREATE = """ + CREATE TABLE $NAME ( + ${Columns.ATP_ENTRY_ID} INTEGER PRIMARY KEY, + ${Columns.TIMESTAMP} int NOT NULL + ); + """ +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/CreateElementUsingAtpQuest.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/CreateElementUsingAtpQuest.kt new file mode 100644 index 00000000000..a8bb18ba312 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/CreateElementUsingAtpQuest.kt @@ -0,0 +1,21 @@ +package de.westnordost.streetcomplete.data.atp.atpquests + +import de.westnordost.streetcomplete.data.atp.AtpEntry +import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry +import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.quest.AtpQuestKey +import de.westnordost.streetcomplete.data.quest.OsmCreateElementQuestType +import de.westnordost.streetcomplete.data.quest.Quest + +/** Represents one task for the user to contribute by reviewing proposed element creation */ +data class CreateElementUsingAtpQuest( + val id: Long, // may be a stable value associated with a given element, but stability is not promised, should be unique + val atpEntry: AtpEntry, + override val type: OsmCreateElementQuestType<*>, + override val position: LatLon +) : Quest { + override val key: AtpQuestKey by lazy { AtpQuestKey(id) } + override val markerLocations: Collection by lazy { listOf(position) } + override val geometry: ElementGeometry get() = ElementPointGeometry(position) +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpDataWithEditsSource.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpDataWithEditsSource.kt new file mode 100644 index 00000000000..455d88bdf94 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpDataWithEditsSource.kt @@ -0,0 +1,70 @@ +package de.westnordost.streetcomplete.data.atp.atpquests.edits + +import de.westnordost.streetcomplete.data.atp.AtpController +import de.westnordost.streetcomplete.data.atp.AtpEntry +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.user.UserDataSource +import de.westnordost.streetcomplete.util.Listeners + +class AtpDataWithEditsSource( + private val atpController: AtpController, + private val atpDataSource: AtpEditsSource, // holds unsynced ones (TODO: not implemented yet, do I even need this? what this would do?) + private val userDataSource: UserDataSource +) { + // TODO see MapDataWithEditsSource and NotesWithEditsSource + // AtpDataWithEditsSource may make sense due to holding not-yet-uploaded + // cases where ATP was surveyed to be a nonsense + + fun get(entryId: Long): AtpEntry? { + var entry = atpController.get(entryId) + return entry + // TODO LATER API: try to take into account unsynced edits, otheriwse there is no point in this class + } + + private val atpControllerListener = object : AtpController.Listener { + override fun onUpdated( + added: Collection, + updated: Collection, + deleted: Collection + ) { + // TODO merge with applied edits? is it even needed like it is for notes? + //val noteCommentEdits = noteEditsSource.getAllUnsynced().filter { it.action != CREATE } + callOnUpdated( + //editsAppliedToNotes(added, noteCommentEdits), + //editsAppliedToNotes(updated, noteCommentEdits), + //deleted + added, + updated, + deleted + ) + } + + override fun onCleared() { + //TODO is it even needed //callOnCleared() + } + } + + init { + atpController.addListener(atpControllerListener) + } + interface Listener { + fun onUpdatedAtpElements(added: Collection, deleted: Collection) + fun onInvalidated() + } + private val listeners = Listeners() + + fun addListener(listener: AtpDataWithEditsSource.Listener) { + listeners.add(listener) + } + fun removeListener(listener: AtpDataWithEditsSource.Listener) { + listeners.remove(listener) + } + private fun callOnUpdated(added: Collection = emptyList(), updated: Collection = emptyList(), deleted: Collection = emptyList()) { + listeners.forEach { it.onUpdatedAtpElements(added, deleted) } + } + + fun getAll(bbox: BoundingBox): Collection { + // TODO: what about taking edits into account, that is whole point of this class + return atpController.getAll(bbox) + } +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpEditsSource.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpEditsSource.kt new file mode 100644 index 00000000000..3eb7a3621b7 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/atp/atpquests/edits/AtpEditsSource.kt @@ -0,0 +1,5 @@ +package de.westnordost.streetcomplete.data.atp.atpquests.edits + +interface AtpEditsSource { + +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/DownloadModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/DownloadModule.kt index b3e9e629b40..b5c98a70ff6 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/DownloadModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/DownloadModule.kt @@ -13,7 +13,7 @@ val downloadModule = module { factory { MobileDataAutoDownloadStrategy(get(), get()) } factory { WifiAutoDownloadStrategy(get(), get()) } - single { Downloader(get(), get(), get(), get(), get(), get(named("SerializeSync"))) } + single { Downloader(get(), get(), get(), get(), get(), get(), get(named("SerializeSync"))) } single { get() } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt index 98a33ed096a..27c70d71d2b 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/download/Downloader.kt @@ -9,6 +9,7 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloader import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.mapdata.MapDataDownloader import de.westnordost.streetcomplete.data.osmnotes.NotesDownloader +import de.westnordost.streetcomplete.data.atp.AtpDownloader import de.westnordost.streetcomplete.data.user.UserLoginController import de.westnordost.streetcomplete.util.Listeners import de.westnordost.streetcomplete.util.ktx.format @@ -24,6 +25,7 @@ import kotlin.math.max /** Downloads all the things */ class Downloader( + private val atpDownloader: AtpDownloader, private val notesDownloader: NotesDownloader, private val mapDataDownloader: MapDataDownloader, private val mapTilesDownloader: MapTilesDownloader, @@ -68,6 +70,7 @@ class Downloader( mutex.withLock { coroutineScope { // all downloaders run concurrently + launch { atpDownloader.download(tilesBbox) } launch { notesDownloader.download(tilesBbox) } launch { mapDataDownloader.download(tilesBbox) } launch { mapTilesDownloader.download(tilesBbox) } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt index 872b8768260..54f32ee405c 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryController.kt @@ -1,6 +1,9 @@ package de.westnordost.streetcomplete.data.edithistory import de.westnordost.streetcomplete.ApplicationConstants.MAX_UNDO_HISTORY_AGE +import de.westnordost.streetcomplete.data.atp.AtpEditsController +import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestHidden +import de.westnordost.streetcomplete.data.atp.atpquests.edits.AtpDataWithEditsSource import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditsController import de.westnordost.streetcomplete.data.osm.edits.ElementEditsSource @@ -13,6 +16,7 @@ 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.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.data.quest.QuestKey @@ -26,9 +30,11 @@ import de.westnordost.streetcomplete.util.ktx.nowAsEpochMilliseconds class EditHistoryController( private val elementEditsController: ElementEditsController, private val noteEditsController: NoteEditsController, + private val atpEditsController: AtpEditsController, private val hiddenQuestsController: QuestsHiddenController, private val notesSource: NotesWithEditsSource, private val mapDataSource: MapDataWithEditsSource, + private val atpDataSource: AtpDataWithEditsSource, private val questTypeRegistry: QuestTypeRegistry, ) : EditHistorySource { private val listeners = Listeners() @@ -74,12 +80,17 @@ class EditHistoryController( val questType = questTypeRegistry.getByName(key.questTypeName) as? OsmElementQuestType<*> ?: return null OsmQuestHidden(key.elementType, key.elementId, questType, geometry, timestamp) } + is AtpQuestKey -> { + val atpEntry = atpDataSource.get(key.atpEntryId) ?: return null + AtpQuestHidden(atpEntry, timestamp) + } } } init { elementEditsController.addListener(osmElementEditsListener) noteEditsController.addListener(osmNoteEditsListener) + //atpEditsController.addListener(osmNoteEditsListener) // what it is even doing? TODO hiddenQuestsController.addListener(questHiddenListener) } @@ -91,6 +102,7 @@ class EditHistoryController( is NoteEdit -> noteEditsController.undo(edit) is OsmNoteQuestHidden -> hiddenQuestsController.unhide(edit.questKey) is OsmQuestHidden -> hiddenQuestsController.unhide(edit.questKey) + is AtpQuestHidden -> hiddenQuestsController.unhide(edit.questKey) else -> throw IllegalArgumentException() } } @@ -106,6 +118,7 @@ class EditHistoryController( val timestamp = hiddenQuestsController.get(key.questKey) if (timestamp != null) createQuestHiddenEdit(key.questKey, timestamp) else null } + //TODO API (once proper ATP API exists) is AtpQuestKey -> atpEditsController.get(key.id) } override fun getAll(): List { @@ -114,6 +127,7 @@ class EditHistoryController( val result = ArrayList() result += elementEditsController.getAll().filter { it.action !is IsRevertAction } result += noteEditsController.getAll() + // atpEditsController not counted here, as these are not OpenStreetMap edits result += hiddenQuestsController.getAllNewerThan(maxAge).mapNotNull { (key, timestamp) -> createQuestHiddenEdit(key, timestamp) } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt index dc19f681548..9bc17df24a8 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/edithistory/EditHistoryModule.kt @@ -4,5 +4,5 @@ import org.koin.dsl.module val editHistoryModule = module { single { get() } - single { EditHistoryController(get(), get(), get(), get(), get(), get()) } + single { EditHistoryController(get(), get(), get(), get(), get(), get(), get(), get()) } } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt index 704954a85cb..4f0412576c4 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/osm/osmquests/OsmElementQuestType.kt @@ -82,16 +82,17 @@ interface OsmElementQuestType : QuestType, ElementEditType { * not (this is slow). */ fun isApplicableTo(element: Element): Boolean? - /** Elements that should be highlighted on the map alongside the selected one because they - * provide context for the given element. For example, nearby benches should be shown when - * answering a question for a bench so the user knows which of the benches is meant. */ - fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry): Sequence = emptySequence() - /** The radius in which certain elements should be shown (see getHighlightedElements). * 30m is the default because this is about "across this large street". There shouldn't be * any misunderstandings which element is meant that far apart. */ val highlightedElementsRadius: Double get() = 30.0 + override fun getHighlightedElementsGeneric(element: Element?, getMapData: () -> MapDataWithGeometry): Sequence { + return getHighlightedElements(element!!, getMapData) + } + + fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) : Sequence = emptySequence() + /** Applies the data from [answer] to the element that has last been edited at [timestampEdited] * with the given [tags] and the given [geometry]. * The element is not directly modified, instead, a map of [tags] is modified */ diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/OsmCreateElementQuestType.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/OsmCreateElementQuestType.kt new file mode 100644 index 00000000000..96b0f9cb773 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/OsmCreateElementQuestType.kt @@ -0,0 +1,11 @@ +package de.westnordost.streetcomplete.data.quest + +import de.westnordost.streetcomplete.data.osm.edits.ElementEditType + +interface OsmCreateElementQuestType : QuestType, ElementEditType { + /** The radius in which certain elements should be shown (see getHighlightedElements). + * 30m is the default because this is about "across this large street". There shouldn't be + * any misunderstandings which element is meant that far apart. */ + // TODO: highlightedElementsRadius and its comment duplicates OsmElementQuestType entry: should it be moved higher? Into interface? This does not apply to Note quests + val highlightedElementsRadius: Double get() = 30.0 +} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestKey.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestKey.kt index 9c1b9a174e5..6fbdbd8db2a 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestKey.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestKey.kt @@ -11,6 +11,10 @@ sealed class QuestKey @SerialName("osmnote") data class OsmNoteQuestKey(val noteId: Long) : QuestKey() +@Serializable +@SerialName("atpquest") +data class AtpQuestKey(val atpEntryId: Long) : QuestKey() + @Serializable @SerialName("osm") data class OsmQuestKey( diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestType.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestType.kt index 99a4962cca1..8575676075f 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestType.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/QuestType.kt @@ -1,6 +1,8 @@ package de.westnordost.streetcomplete.data.quest import de.westnordost.streetcomplete.data.osm.edits.EditType +import de.westnordost.streetcomplete.data.osm.mapdata.Element +import de.westnordost.streetcomplete.data.osm.mapdata.MapDataWithGeometry /** A quest type appears as a pin with an icon on the map and when opened, the quest type's * question is displayed along with a UI to answer that quest. @@ -19,4 +21,9 @@ interface QuestType : EditType { /** The quest type can clean it's metadata that is older than the given timestamp here, if any */ fun deleteMetadataOlderThan(timestamp: Long) {} + + /** Elements that should be highlighted on the map alongside the selected one because they + * provide context for the given element. For example, nearby benches should be shown when + * answering a question for a bench so the user knows which of the benches is meant. */ + fun getHighlightedElementsGeneric(element: Element?, getMapData: () -> MapDataWithGeometry): Sequence = emptySequence() } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt index 69652e20076..f2c8aae8eea 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/VisibleQuestsSource.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.osm.edits.EditType import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuest @@ -36,6 +38,7 @@ class VisibleQuestsSource( private val questTypeRegistry: QuestTypeRegistry, private val osmQuestSource: OsmQuestSource, private val osmNoteQuestSource: OsmNoteQuestSource, + private val atpQuestSource: AtpQuestSource, private val questsHiddenSource: QuestsHiddenSource, private val visibleEditTypeSource: VisibleEditTypeSource, private val teamModeQuestFilter: TeamModeQuestFilter, @@ -73,6 +76,15 @@ class VisibleQuestsSource( } } + private val atpQuestSourceListener = object : AtpQuestSource.Listener { + override fun onUpdated(added: Collection, deleted: Collection) { + updateVisibleQuests(added, deleted.map { AtpQuestKey(it) }) + } + override fun onInvalidated() { + // apparently the visibility of many different quests have changed + invalidate() + } + } private val questsHiddenSourceListener = object : QuestsHiddenSource.Listener { override fun onHid(key: QuestKey, timestamp: Long) { updateVisibleQuests(deleted = listOf(key)) @@ -82,6 +94,7 @@ class VisibleQuestsSource( val quest = when (key) { is OsmQuestKey -> osmQuestSource.get(key) is OsmNoteQuestKey -> osmNoteQuestSource.get(key.noteId) + is AtpQuestKey -> atpQuestSource.get(key.atpEntryId) } ?: return updateVisibleQuests(added = listOf(quest)) } @@ -126,6 +139,7 @@ class VisibleQuestsSource( init { osmQuestSource.addListener(osmQuestSourceListener) osmNoteQuestSource.addListener(osmNoteQuestSourceListener) + atpQuestSource.addListener(atpQuestSourceListener) questsHiddenSource.addListener(questsHiddenSourceListener) visibleEditTypeSource.addListener(visibleEditTypeSourceListener) teamModeQuestFilter.addListener(teamModeQuestFilterListener) @@ -145,7 +159,8 @@ class VisibleQuestsSource( val quests = osmQuestSource.getAllInBBox(bbox, visibleQuestTypeNames) + - osmNoteQuestSource.getAllInBBox(bbox) + osmNoteQuestSource.getAllInBBox(bbox) + + atpQuestSource.getAllInBBox(bbox) return quests.filter { isVisible(it.key) && isVisibleInTeamMode(it) } } @@ -154,6 +169,7 @@ class VisibleQuestsSource( val quest = cache.get(questKey) ?: when (questKey) { is OsmNoteQuestKey -> osmNoteQuestSource.get(questKey.noteId) is OsmQuestKey -> osmQuestSource.get(questKey) + is AtpQuestKey -> atpQuestSource.get(questKey.atpEntryId) } ?: return null return if (isVisible(quest)) quest else null } @@ -184,7 +200,7 @@ class VisibleQuestsSource( private fun updateVisibleQuests( added: Collection = emptyList(), - deleted: Collection = emptyList() + deleted: Collection = emptyList() // quests deleted already may be listed again ) { lock.withLock { val addedVisible = added.filter(::isVisible) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt index 716497644ad..80c2cccd58f 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/QuestsHiddenController.kt @@ -1,7 +1,9 @@ package de.westnordost.streetcomplete.data.visiblequests +import de.westnordost.streetcomplete.data.atp.atpquests.AtpQuestsHiddenDao import de.westnordost.streetcomplete.data.osm.osmquests.OsmQuestsHiddenDao 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.OsmQuestKey import de.westnordost.streetcomplete.data.quest.QuestKey @@ -13,6 +15,7 @@ import kotlinx.atomicfu.locks.withLock class QuestsHiddenController( private val osmDb: OsmQuestsHiddenDao, private val notesDb: NoteQuestsHiddenDao, + private val atpDb: AtpQuestsHiddenDao, ) : QuestsHiddenSource, HideQuestController { /* Must be a singleton because there is a listener that should respond to a change in the @@ -26,9 +29,11 @@ class QuestsHiddenController( cacheLock.withLock { val allOsmHidden = osmDb.getAll() val allNotesHidden = notesDb.getAll() - val result = HashMap(allOsmHidden.size + allNotesHidden.size) + val allAtpHidden = atpDb.getAll() + val result = HashMap(allOsmHidden.size + allNotesHidden.size + allAtpHidden.size) allOsmHidden.forEach { result[it.key] = it.timestamp } allNotesHidden.forEach { result[OsmNoteQuestKey(it.noteId)] = it.timestamp } + allAtpHidden.forEach { result[AtpQuestKey(it.allThePlacesEntryId)] = it.timestamp } result } } @@ -40,6 +45,7 @@ class QuestsHiddenController( when (key) { is OsmQuestKey -> osmDb.add(key) is OsmNoteQuestKey -> notesDb.add(key.noteId) + is AtpQuestKey -> atpDb.add(key.atpEntryId) } timestamp = getTimestamp(key) ?: return cache[key] = timestamp @@ -55,6 +61,7 @@ class QuestsHiddenController( val result = when (key) { is OsmQuestKey -> osmDb.delete(key) is OsmNoteQuestKey -> notesDb.delete(key.noteId) + is AtpQuestKey -> atpDb.delete(key.atpEntryId) } if (!result) return false cache.remove(key) @@ -67,13 +74,14 @@ class QuestsHiddenController( when (key) { is OsmQuestKey -> osmDb.getTimestamp(key) is OsmNoteQuestKey -> notesDb.getTimestamp(key.noteId) + is AtpQuestKey -> atpDb.getTimestamp(key.atpEntryId) } /** Un-hides all previously hidden quests by user interaction */ fun unhideAll(): Int { var unhidCount = 0 cacheLock.withLock { - unhidCount = osmDb.deleteAll() + notesDb.deleteAll() + unhidCount = osmDb.deleteAll() + notesDb.deleteAll() + atpDb.deleteAll() cache.clear() } listeners.forEach { it.onUnhidAll() } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt index 42ee843a62a..be53c1586ac 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/visiblequests/VisibleQuestsModule.kt @@ -12,7 +12,7 @@ val visibleQuestsModule = module { single { TeamModeQuestFilter(get(), get()) } single { get() } - single { QuestsHiddenController(get(), get()) } + single { QuestsHiddenController(get(), get(), get()) } single { get() } single { VisibleEditTypeController(get(), get(), get()) } diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParserTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParserTest.kt new file mode 100644 index 00000000000..08601a2932d --- /dev/null +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/atp/AtpApiParserTest.kt @@ -0,0 +1,156 @@ +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 kotlinx.io.Buffer +import kotlinx.io.writeString +import kotlin.test.Test +import kotlin.test.assertEquals + +class AtpApiParserTest { + @Test + fun `parse empty response`() { + val buffer = Buffer() + // https://bbox-filter-for-atp.bulwersator-cloudflare.workers.dev/api/entries?lat_min=50.3&lat_max=50.33&lon_min=19.9&lon_max=19.95 + buffer.writeString("""[]""") + assertEquals(listOf(), AtpApiParser().parseAtpEntries(buffer)) + } + + @Test + fun `parse one minimum atp entry`() { + val buffer = Buffer() + // https://bbox-filter-for-atp.bulwersator-cloudflare.workers.dev/api/entries?lat_min=50.2839&lat_max=50.35&lon_min=19.9&lon_max=19.95 + buffer.writeString("""[ + { + "atp_entry_id": -3097502835224812, + "atp_center_lat": 50.337333, + "atp_center_lon": 19.926502, + "atp_tags": "{\"ref\": \"AL004KMI\", \"description\": \"Na placu\", \"@source_uri\": \"https://edge.allegro.pl/general-deliveries/2595554\", \"@spider\": \"allegro_one_box_pl\", \"amenity\": \"parcel_locker\", \"addr:street_address\": \"Go\\u0142cza 52\", \"addr:city\": \"Go\\u0142cza\", \"addr:country\": \"PL\", \"website\": \"https://allegro.pl/kampania/one/znajdz-nas?pointId=2595554\", \"opening_hours\": \"24/7\", \"brand\": \"Allegro One Box\", \"brand:wikidata\": \"Q110738715\", \"atp_id\": \"joZ8fm6HJhR0l2oH6b9TgijR_EI=\", \"atp_ref\": \"AL004KMI\"}", + "osm_match_center_lat": null, + "osm_match_center_lon": null, + "osm_match_tags": "null", + "osm_element_match_id": null, + "osm_element_match_type": null, + "match_distance": null, + "all_very_good_matches": "null", + "report_type": "MISSING_POI_IN_OPENSTREETMAP", + "is_marked_as_bad_by_mapper": 0 + } +]""" + ) + + val atpEntry = AtpEntry( + position = LatLon(50.337333, 19.926502), + id = -3097502835224812, + osmMatch = null, + tagsInATP = mapOf( + "ref" to "AL004KMI", + "description" to "Na placu", + "@source_uri" to "https://edge.allegro.pl/general-deliveries/2595554", + "@spider" to "allegro_one_box_pl", + "amenity" to "parcel_locker", + "addr:street_address" to "Gołcza 52", + "addr:city" to "Gołcza", + "addr:country" to "PL", "website" to "https://allegro.pl/kampania/one/znajdz-nas?pointId=2595554", + "opening_hours" to "24/7", "brand" to "Allegro One Box", + "brand:wikidata" to "Q110738715", + "atp_id" to "joZ8fm6HJhR0l2oH6b9TgijR_EI=", + "atp_ref" to "AL004KMI" + ), + tagsInOSM = null, + reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP + ) + + assertEquals(listOf(atpEntry), AtpApiParser().parseAtpEntries(buffer)) + } + + @Test + fun `parse two more complete atp entries`() { + val buffer = Buffer() + // https://bbox-filter-for-atp.bulwersator-cloudflare.workers.dev/api/entries?lat_min=50.2838&lat_max=50.35&lon_min=19.9&lon_max=19.95 + buffer.writeString("""[ + { + "atp_entry_id": -3097502835224812, + "atp_center_lat": 50.337333, + "atp_center_lon": 19.926502, + "atp_tags": "{\"ref\": \"AL004KMI\", \"description\": \"Na placu\", \"@source_uri\": \"https://edge.allegro.pl/general-deliveries/2595554\", \"@spider\": \"allegro_one_box_pl\", \"amenity\": \"parcel_locker\", \"addr:country\": \"PL\", \"website\": \"https://allegro.pl/kampania/one/znajdz-nas?pointId=2595554\", \"opening_hours\": \"24/7\", \"brand\": \"Allegro One Box\", \"brand:wikidata\": \"Q110738715\", \"atp_id\": \"joZ8fm6HJhR0l2oH6b9TgijR_EI=\", \"atp_ref\": \"AL004KMI\"}", + "osm_match_center_lat": null, + "osm_match_center_lon": null, + "osm_match_tags": "null", + "osm_element_match_id": null, + "osm_element_match_type": null, + "match_distance": null, + "all_very_good_matches": "null", + "report_type": "MISSING_POI_IN_OPENSTREETMAP", + "is_marked_as_bad_by_mapper": 0 + }, + { + "atp_entry_id": 1834581365633738, + "atp_center_lat": 50.283805, + "atp_center_lon": 19.920721, + "atp_tags": "{\"ref\": \"AL005KMI\", \"description\": \"Na placu\", \"@source_uri\": \"https://edge.allegro.pl/general-deliveries/2593315\", \"@spider\": \"allegro_one_box_pl\", \"amenity\": \"parcel_locker\", \"addr:country\": \"PL\", \"website\": \"https://allegro.pl/kampania/one/znajdz-nas?pointId=2593315\", \"opening_hours\": \"24/7\", \"brand\": \"Allegro One Box\", \"brand:wikidata\": \"Q110738715\", \"atp_id\": \"G5H5NoXhe_2QjVhvZt9LjxzJ6sE=\", \"atp_ref\": \"AL005KMI\"}", + "osm_match_center_lat": 50.28380, + "osm_match_center_lon": 19.92072, + "osm_match_tags": "{\"amenity\": \"parcel_locker\", \"brand\": \"Allegro One Box\", \"brand:wikidata\": \"Q110738715\"}", + "osm_element_match_id": null, + "osm_element_match_type": null, + "match_distance": null, + "all_very_good_matches": "null", + "report_type": "OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP", + "is_marked_as_bad_by_mapper": 0 + } +]""") + val atpEntries = listOf( + AtpEntry( + position = LatLon(50.337333, 19.926502), + id = -3097502835224812, + osmMatch = null, + tagsInATP = mapOf( + "ref" to "AL004KMI", + "description" to "Na placu", + "@source_uri" to "https://edge.allegro.pl/general-deliveries/2595554", + "@spider" to "allegro_one_box_pl", + "amenity" to "parcel_locker", + "addr:country" to "PL", + "website" to "https://allegro.pl/kampania/one/znajdz-nas?pointId=2595554", + "opening_hours" to "24/7", + "brand" to "Allegro One Box", + "brand:wikidata" to "Q110738715", + "atp_id" to "joZ8fm6HJhR0l2oH6b9TgijR_EI=", + "atp_ref" to "AL004KMI", + ), + tagsInOSM = null, + reportType = ReportType.MISSING_POI_IN_OPENSTREETMAP + ), + AtpEntry( + position = LatLon(50.283805, 19.920721), + id = 1834581365633738, + osmMatch = null, // ElementKey(ElementType.WAY, 403376332), // or similar expected TODO wait for API to support it + tagsInATP = mapOf( + "ref" to "AL005KMI", + "description" to "Na placu", + "@source_uri" to "https://edge.allegro.pl/general-deliveries/2593315", + "@spider" to "allegro_one_box_pl", + "amenity" to "parcel_locker", + "addr:country" to "PL", + "website" to "https://allegro.pl/kampania/one/znajdz-nas?pointId=2593315", + "opening_hours" to "24/7", + "brand" to "Allegro One Box", + "brand:wikidata" to "Q110738715", + "atp_id" to "G5H5NoXhe_2QjVhvZt9LjxzJ6sE=", + "atp_ref" to "AL005KMI", + ), + tagsInOSM = mapOf( + "amenity" to "parcel_locker", + "brand" to "Allegro One Box", + "brand:wikidata" to "Q110738715", + ), + reportType = ReportType.OPENING_HOURS_REPORTED_AS_OUTDATED_IN_OPENSTREETMAP + ), + ) + + assertEquals(atpEntries, AtpApiParser().parseAtpEntries(buffer)) + } + +} diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts.kt index ee1fa47e473..dfb39aa31a7 100644 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts.kt +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/testutils/TestDataShortcuts.kt @@ -1,5 +1,7 @@ package de.westnordost.streetcomplete.testutils +import de.westnordost.streetcomplete.data.atp.AtpEntry +import de.westnordost.streetcomplete.data.atp.ReportType import de.westnordost.streetcomplete.data.osm.edits.ElementEdit import de.westnordost.streetcomplete.data.osm.edits.ElementEditAction import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChanges @@ -8,7 +10,9 @@ import de.westnordost.streetcomplete.data.osm.edits.update_tags.UpdateElementTag import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry import de.westnordost.streetcomplete.data.osm.geometry.ElementPointGeometry import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +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.Node import de.westnordost.streetcomplete.data.osm.mapdata.Relation @@ -62,7 +66,7 @@ fun waysAsMembers(wayIds: List, role: String = ""): List = fun pGeom(lat: Double = 0.0, lon: Double = 0.0) = ElementPointGeometry(p(lat, lon)) -fun note( +fun note( // TODO: repeats "private fun createNote(" in NoteDaoTest - can it be avoided? id: Long = 1, position: LatLon = p(0.0, 0.0), timestamp: Long = 0, @@ -116,3 +120,19 @@ fun edit( action, isNearUserLocation ) + +fun atpEntry( + 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, +)