From edef590b0b07cd853af672d42b7fb468e49c9f6b Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Sat, 12 Sep 2026 13:08:55 +0100 Subject: [PATCH 1/2] Ask once which country the places nearby are in process() looked the country up from each place's own coordinates, once for the address's fallback country code and again inside japaneseAddress(), and each of those is an R-tree query over the country boundaries plus a point-in-polygon test for every candidate. filterLocations() builds fresh LocationDescriptions on every uiState change - roughly once a second while the screen is open - so the whole visible list was re-resolved that often. Everything in the list is within a grid of the user and so in the same country they are, so PlacesNearbyViewModel looks it up from their own location and carries it in the ui state for the rows to share. It only asks again once they have moved 5km, which a country is not something you do by accident. process() takes that as an optional argument and falls back to the feature's own location without one, which is what a search result on the other side of the world needs. japaneseAddress() takes it as a lambda rather than a value so a feature with no quarter or neighbourhood still short-circuits before the lookup. Near a border a place a few hundred metres over the line is now written the way the user's side writes addresses rather than its own. For a list of what's nearby that's the better answer, and the only behaviour this changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ --- .../home/placesnearby/PlacesNearbyList.kt | 2 +- .../home/placesnearby/PlacesNearbyUiState.kt | 6 ++++ .../placesnearby/PlacesNearbyViewModel.kt | 29 ++++++++++++++++++- .../addandeditroutescreen/AddWaypointsList.kt | 2 +- .../soundscape/utils/FeatureLocationExt.kt | 21 ++++++++++---- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyList.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyList.kt index d7b0b4fc4..0dcb0f8e1 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyList.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyList.kt @@ -68,7 +68,7 @@ fun PlacesNearbyList( color = MaterialTheme.colorScheme.outlineVariant ) } - locationDescription.process(localizedStrings) + locationDescription.process(localizedStrings, uiState.countryCode) LocationItem( item = locationDescription, decoration = LocationItemDecoration( diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyUiState.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyUiState.kt index 996cdd8c9..84cf0f120 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyUiState.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyUiState.kt @@ -6,6 +6,12 @@ import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription data class PlacesNearbyUiState( var userLocation: LngLatAlt? = null, + /** + * The country whose address conventions every place in the list is written in, looked up once + * from [userLocation]. Everything nearby is by definition in the same country as the user, so + * the rows share the one answer instead of each asking the country boundaries themselves. + */ + var countryCode: String? = null, var level: Int = 0, var nearbyPlaces: FeatureCollection = FeatureCollection(), var nearbyIntersections: FeatureCollection = FeatureCollection(), diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt index 0103bcb27..d1b88da0f 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyViewModel.kt @@ -14,9 +14,14 @@ import kotlinx.coroutines.withContext import org.scottishtecharmy.soundscape.audio.AudioTour import org.scottishtecharmy.soundscape.geoengine.GridState import org.scottishtecharmy.soundscape.geoengine.TreeId +import org.scottishtecharmy.soundscape.geoengine.utils.rulers.CheapRuler import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.services.ServiceConnection +import org.scottishtecharmy.soundscape.utils.addressCountryCode + +/** How far the user has to move before the country their address is written in is looked up again. */ +private const val COUNTRY_LOOKUP_DISTANCE = 5000.0 /** * Shared ViewModel backing the PlacesNearby screen on both Android and iOS. @@ -68,6 +73,25 @@ open class PlacesNearbyViewModel( private data class LocationAndGridState(val location: LngLatAlt?, val gridState: GridState?) + private var countryLookupLocation: LngLatAlt? = null + private var countryCode: String? = null + + /** + * The country [location] is in, looked up from the country boundaries the first time and then + * only again once the user has moved far enough for the answer to plausibly have changed. A + * location update arrives every second or so, and a country is not something you walk out of. + */ + private fun countryCodeFor(location: LngLatAlt): String? { + val lastLookup = countryLookupLocation + if ((lastLookup == null) || + (CheapRuler(location.latitude).distance(lastLookup, location) > COUNTRY_LOOKUP_DISTANCE) + ) { + countryLookupLocation = location + countryCode = addressCountryCode(location) + } + return countryCode + } + @OptIn(ExperimentalCoroutinesApi::class) private fun startMonitoring() { monitorJob?.cancel() @@ -80,7 +104,10 @@ open class PlacesNearbyViewModel( ) }.collect { locationAndGrid -> if (locationAndGrid.location != null) { - internalUiState.update { it.copy(userLocation = locationAndGrid.location) } + val country = countryCodeFor(locationAndGrid.location) + internalUiState.update { + it.copy(userLocation = locationAndGrid.location, countryCode = country) + } } if (locationAndGrid.gridState != null) { val (pois, intersections) = withContext(locationAndGrid.gridState.treeContext) { diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt index db3390287..c690bbfd2 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/addandeditroutescreen/AddWaypointsList.kt @@ -161,7 +161,7 @@ fun AddWaypointsList( color = MaterialTheme.colorScheme.outlineVariant ) } - locationDescription.process(localizedStrings) + locationDescription.process(localizedStrings, placesNearbyUiState.countryCode) LocationItem( item = locationDescription, decoration = LocationItemDecoration( diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt index f09e609fb..e54476d6c 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt @@ -79,11 +79,11 @@ internal fun addressCountryCode(location: LngLatAlt): String = * null if it isn't one. The parts of it are the [mvt]'s own for a feature from the grid, and in * [properties] for a search result. */ -private fun japaneseAddress(mvt: MvtFeature?, properties: Map, location: LngLatAlt): String? { +private fun japaneseAddress(mvt: MvtFeature?, properties: Map, countryCode: () -> String): String? { fun part(field: String?, key: String) = field ?: (properties[key] as? String) val quarter = part(mvt?.quarter, "quarter") val neighbourhood = part(mvt?.neighbourhood, "neighbourhood") - if (((quarter == null) && (neighbourhood == null)) || (addressCountryCode(location) != "JP")) return null + if (((quarter == null) && (neighbourhood == null)) || (countryCode() != "JP")) return null return JapaneseAddress.address( quarter, neighbourhood, @@ -112,7 +112,17 @@ private fun streetAddressLine(formattedAddress: String, road: String?, houseNumb } } -fun LocationDescription.process(strings: LocalizedStrings? = null) { +/** + * Fills in the name and address a [LocationDescription] shows, from the feature it was deferred + * from. Deferred because a list of a thousand nearby POIs only ever formats the handful of rows + * which reach the screen. + * + * [countryCode] is the country whose address conventions to write the address in, for a caller + * which already knows it - a list of places all within a kilometre or two of the user needs only + * one boundary lookup between them, not one per row. Left out, the feature's own location is + * looked up, which is what a search result somewhere else in the world needs. + */ +fun LocationDescription.process(strings: LocalizedStrings? = null, countryCode: String? = null) { if (feature != null) { feature?.let { feature -> var address = false @@ -122,6 +132,7 @@ fun LocationDescription.process(strings: LocalizedStrings? = null) { val mvt = (feature as? MvtFeature) var nameLocal: String? = null var blockAddress: String? = null + val addressCountry by lazy { countryCode ?: addressCountryCode(location) } feature.properties?.let { properties -> properties.forEach { (key, value) -> @@ -170,7 +181,7 @@ fun LocationDescription.process(strings: LocalizedStrings? = null) { } // Most Japanese buildings are numbered within their block, and their address is // the ward and then that - "北区, 梅田三丁目1-1" - with no street in it - blockAddress = japaneseAddress(mvt, properties, location) + blockAddress = japaneseAddress(mvt, properties) { addressCountry } blockAddress?.let { jsonFields.remove("road") jsonFields["house_number"] = it @@ -199,7 +210,7 @@ fun LocationDescription.process(strings: LocalizedStrings? = null) { var fallbackCountryCode: String? = null if (!jsonFields.containsKey("country_code")) - fallbackCountryCode = addressCountryCode(location) + fallbackCountryCode = addressCountry if (fallbackCountryCode?.isEmpty() == true) fallbackCountryCode = "GB" val formattedAddress = try { From 3ce81c337b7a71fd7e5788c36364a377b6f40bb0 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Sat, 12 Sep 2026 13:27:07 +0100 Subject: [PATCH 2/2] Don't configure :app in the nightly iOS build ef4feb9af added --configure-on-demand to the ios-build and ios-firebase jobs in build-app.yaml, but nightly.yaml has its own copy of the same :shared:compileTestKotlinIosSimulatorArm64 step and was left as it was, so the nightly iOS build still fails on the self-hosted Mac: * What went wrong: A problem occurred configuring project ':app'. > java.lang.NullPointerException (no error message) which is AGP's NdkLocator dereferencing a null File.list() on $ANDROID_HOME/ndk. Same flag, same reason: this job only needs :shared. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ --- .github/workflows/nightly.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 79e8c7108..fdb1a2643 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -265,8 +265,14 @@ jobs: # See run-tests.yaml: linking iosSimulatorArm64Test fails because of # maplibre-compose's embedded linker flags. xcodebuild archive below # builds via the Xcode/SPM toolchain that resolves MapLibre. + # + # --configure-on-demand keeps Gradle from configuring :app, which this + # job doesn't need: on the self-hosted Mac, AGP's NdkLocator hits a + # File.list() == null on $ANDROID_HOME/ndk and throws an unguarded NPE + # ("A problem occurred configuring project ':app'"). Same fix as the + # ios-build/ios-firebase jobs in build-app.yaml. - name: Compile shared Kotlin/Native tests for iOS simulator - run: ./gradlew :shared:compileTestKotlinIosSimulatorArm64 + run: ./gradlew --configure-on-demand :shared:compileTestKotlinIosSimulatorArm64 - name: Setup iOS signing uses: ./.github/actions/setup-ios-signing