diff --git a/LICENSE.md b/LICENSE.md index 634ed2946..3cfd9dc50 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -126,3 +126,22 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ``` + +## Open Location Code +The plus code decoder in +`shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode` +is taken from Aught One's Kotlin Multiplatform port of Open Location Code, +https://github.com/aughtone/aughtone-openlocationcode (version 0.0.1-alpha2), +with its package renamed. It is licensed under the Apache License, Version 2.0, +which is available at http://www.apache.org/licenses/LICENSE-2.0. Its NOTICE reads: + +``` +Aught One — Open Location Code +Copyright 2026 The Aught One Authors + +This product includes software derived from Open Location Code +(https://github.com/google/open-location-code) +Copyright 2014 Google Inc. + +Open Location Code is licensed under the Apache License, Version 2.0. +``` diff --git a/app/src/androidTest/java/org/scottishtecharmy/soundscape/OpenInMapAppTest.kt b/app/src/androidTest/java/org/scottishtecharmy/soundscape/OpenInMapAppTest.kt new file mode 100644 index 000000000..ecbd9e4a3 --- /dev/null +++ b/app/src/androidTest/java/org/scottishtecharmy/soundscape/OpenInMapAppTest.kt @@ -0,0 +1,293 @@ +package org.scottishtecharmy.soundscape + +import android.accessibilityservice.AccessibilityServiceInfo +import android.view.accessibility.AccessibilityNodeInfo +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.scottishtecharmy.soundscape.components.LocationListActions +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt +import org.scottishtecharmy.soundscape.preferences.PreferenceKeys +import org.scottishtecharmy.soundscape.preferences.PreferencesListener +import org.scottishtecharmy.soundscape.preferences.PreferencesProvider +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.MapApp +import org.scottishtecharmy.soundscape.screens.home.locationDetails.SharedLocationDetailsScreen +import org.scottishtecharmy.soundscape.screens.markers_routes.screens.MarkersAndRoutesList +import org.scottishtecharmy.soundscape.screens.markers_routes.screens.MarkersAndRoutesUiState +import org.scottishtecharmy.soundscape.ui.theme.SoundscapeTheme + +/** + * What TalkBack is given for Location Details' "Open in Maps App" button and its chooser. adb + * can't steer TalkBack (injected touches and keys bypass it), so these read the platform + * AccessibilityNodeInfo tree - the same objects TalkBack speaks from. + * + * - With an app remembered, the long press carries a label, so TalkBack says "double tap and + * hold to choose a different app", and there's no duplicate custom action (that is iOS only). + * - The chooser's apps are a list, so TalkBack says "in list, N items" and each item's position. + */ +class OpenInMapAppTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val apps = listOf( + MapApp("com.google.android.apps.maps", "Maps"), + MapApp("com.waze", "Waze"), + MapApp("net.osmand", "OsmAnd"), + ) + + private val marker = LocationDescription( + name = "Buchanan Galleries", + location = LngLatAlt(-4.2518, 55.86421), + databaseId = 7, + ) + + private class Prefs(initial: Map) : PreferencesProvider { + val strings = initial.toMutableMap() + private val listeners = mutableListOf() + override fun getBoolean(key: String, default: Boolean) = key != "ShowMap" && default + override fun getString(key: String, default: String) = strings[key] ?: default + override fun getFloat(key: String, default: Float) = default + override fun putBoolean(key: String, value: Boolean) {} + override fun putString(key: String, value: String) { + strings[key] = value + listeners.toList().forEach { it.onPreferenceChanged(key) } + } + override fun clearAll() {} + override fun addListener(listener: PreferencesListener) { listeners += listener } + override fun removeListener(listener: PreferencesListener) { listeners -= listener } + } + + private fun show(prefs: Prefs) { + composeTestRule.setContent { + SoundscapeTheme(MutableStateFlow(ThemeState())) { + SharedLocationDetailsScreen( + locationDescription = LocationDescription( + name = "Buchanan Galleries", + location = LngLatAlt(-4.2518, 55.86421), + ), + userLocation = null, + preferencesProvider = prefs, + onNavigateUp = {}, + onStartBeacon = { _, _ -> }, + mapApps = apps, + onOpenInMapApp = { _, _ -> }, + ) + } + } + composeTestRule.waitForIdle() + } + + private val standardIds = setOf( + AccessibilityNodeInfo.ACTION_CLICK, + AccessibilityNodeInfo.ACTION_LONG_CLICK, + AccessibilityNodeInfo.ACTION_FOCUS, + AccessibilityNodeInfo.ACTION_CLEAR_FOCUS, + AccessibilityNodeInfo.ACTION_SELECT, + AccessibilityNodeInfo.ACTION_CLEAR_SELECTION, + AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, + AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + AccessibilityNodeInfo.ACTION_SET_SELECTION, + android.R.id.accessibilityActionShowOnScreen, + ) + + /** The labels of a node's custom actions, in the order TalkBack lists them. */ + private fun AccessibilityNodeInfo.customActionLabels(): List = + actionList.filter { it.id !in standardIds }.mapNotNull { it.label?.toString() } + + private fun AccessibilityNodeInfo.performCustomAction(label: String) { + val action = actionList.first { it.label?.toString() == label } + assertTrue("$label failed", performAction(action.id)) + composeTestRule.waitForIdle() + } + + private fun showMarkers(prefs: Prefs, actions: (Prefs) -> LocationListActions) { + composeTestRule.setContent { + SoundscapeTheme(MutableStateFlow(ThemeState())) { + MarkersAndRoutesList( + uiState = MarkersAndRoutesUiState(entries = listOf(marker), markers = true), + userLocation = null, + onSelect = {}, + itemActions = actions(prefs), + ) + } + } + composeTestRule.waitForIdle() + } + + private fun roots(): List { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + automation.serviceInfo = automation.serviceInfo.apply { + flags = flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + return automation.windows.mapNotNull { it.root } + } + + private fun AccessibilityNodeInfo.descendants(): Sequence = sequence { + yield(this@descendants) + for (i in 0 until childCount) getChild(i)?.let { yieldAll(it.descendants()) } + } + + /** + * The clickable node TalkBack focuses for [text]. Compose's node provider doesn't implement + * findAccessibilityNodeInfosByText, so walk every window's tree. + */ + private fun findByText(text: String): AccessibilityNodeInfo { + // Compose only builds its accessibility tree once a service is attached, so give it time. + var node: AccessibilityNodeInfo? = null + var seen = emptyList() + repeat(50) { + if (node != null) return@repeat + val all = roots().flatMap { it.descendants().toList() } + seen = all.mapNotNull { it.text?.toString() } + node = all.firstOrNull { + // List items read their name first, then address and distance. + it.text?.toString() == text || + it.contentDescription?.toString()?.startsWith(text) == true + } + if (node == null) Thread.sleep(100) + } + assertNotNull("No accessibility node with text '$text' in $seen", node) + var n: AccessibilityNodeInfo? = node + while (n != null && !n.isClickable) n = n.parent + return n ?: node!! + } + + @Test + fun rememberedApp_longPressIsLabelled_andNoDuplicateCustomAction() { + show(Prefs(mapOf(PreferenceKeys.PREFERRED_MAP_APP to "com.waze"))) + + val semantics = composeTestRule + .onNodeWithTag("locationDetailsOpenInMapApp").fetchSemanticsNode().config + assertEquals( + "Choose a different app", + semantics.getOrNull(SemanticsActions.OnLongClick)?.label, + ) + assertEquals(null, semantics.getOrNull(SemanticsActions.CustomActions)) + + val node = findByText("Open in Waze") + // TalkBack reads the click label as "Double tap to view this location in Waze". + assertEquals( + "view this location in Waze", + node.actionList.first { it.id == AccessibilityNodeInfo.ACTION_CLICK }.label, + ) + val longClick = node.actionList.firstOrNull { + it.id == AccessibilityNodeInfo.ACTION_LONG_CLICK + } + assertNotNull("TalkBack should be offered a long press", longClick) + assertEquals("Choose a different app", longClick!!.label) + // Only standard actions: no custom "Choose a different app" duplicating the long press. + val labelled = node.actionList.filter { it.id !in standardIds }.map { it.label } + assertFalse("Unexpected custom actions $labelled", labelled.contains("Choose a different app")) + } + + @Test + fun noRememberedApp_hasNoLongPress() { + show(Prefs(emptyMap())) + val node = findByText("Open in Maps App") + assertEquals( + "view this location in another map or navigation app", + node.actionList.first { it.id == AccessibilityNodeInfo.ACTION_CLICK }.label, + ) + assertFalse(node.actionList.any { it.id == AccessibilityNodeInfo.ACTION_LONG_CLICK }) + } + + @Test + fun chooser_isATalkBackList() { + show(Prefs(emptyMap())) + composeTestRule.onNodeWithTag("locationDetailsOpenInMapApp").performClick() + composeTestRule.onNodeWithText("Choose which app to open with").fetchSemanticsNode() + + val listSemantics = composeTestRule + .onNodeWithTag("mapAppChoice_com.waze").fetchSemanticsNode() + .config.getOrNull(SemanticsProperties.CollectionItemInfo) + assertEquals(1, listSemantics?.rowIndex) + + val first = findByText("Maps") + var list: AccessibilityNodeInfo? = first.parent + while (list != null && list.collectionInfo == null) list = list.parent + assertNotNull("The apps should be inside a node with collection info", list) + assertEquals(apps.size, list!!.collectionInfo.rowCount) + assertEquals(1, list.collectionInfo.columnCount) + + apps.forEachIndexed { index, app -> + val item = findByText(app.name) + assertNotNull("${app.name} should have list position", item.collectionItemInfo) + assertEquals(index, item.collectionItemInfo.rowIndex) + } + } + + @Test + fun markersList_offersOpenInRememberedAppAndShare() { + var opened: Pair? = null + var shared: LocationDescription? = null + showMarkers(Prefs(mapOf(PreferenceKeys.PREFERRED_MAP_APP to "com.waze"))) { prefs -> + LocationListActions( + mapApps = apps, + preferencesProvider = prefs, + onOpenInMapApp = { app, desc -> opened = app to desc }, + onShare = { shared = it }, + ) + } + + val item = findByText("Buchanan Galleries") + assertEquals( + listOf("Start audio beacon at this marker", "Open in Waze", "Share"), + item.customActionLabels(), + ) + + item.performCustomAction("Open in Waze") + assertEquals("com.waze", opened?.first?.id) + assertEquals("Buchanan Galleries", opened?.second?.name) + + item.performCustomAction("Share") + assertEquals(7L, shared?.databaseId) + } + + @Test + fun markersList_openInWithNoRememberedApp_asksWhichApp() { + var opened: MapApp? = null + showMarkers(Prefs(emptyMap())) { prefs -> + LocationListActions( + mapApps = apps, + preferencesProvider = prefs, + onOpenInMapApp = { app, _ -> opened = app }, + ) + } + + val item = findByText("Buchanan Galleries") + // No share callback, so no Share action. + assertEquals( + listOf("Start audio beacon at this marker", "Open in Maps App"), + item.customActionLabels(), + ) + item.performCustomAction("Open in Maps App") + composeTestRule.onNodeWithText("Choose which app to open with").fetchSemanticsNode() + composeTestRule.onNodeWithTag("mapAppChoice_net.osmand").performClick() + assertEquals("net.osmand", opened?.id) + } + + @Test + fun markersList_withoutActions_keepsJustTheBeacon() { + showMarkers(Prefs(emptyMap())) { LocationListActions() } + assertEquals( + listOf("Start audio beacon at this marker"), + findByText("Buchanan Galleries").customActionLabels(), + ) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db615ffb5..50164ff4b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -202,6 +202,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt index 58afc89e0..a5480113c 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt @@ -291,6 +291,10 @@ fun HomeScreen( onShareLocation = { desc, shareMessage -> org.scottishtecharmy.soundscape.utils.shareLocation(context, shareMessage, desc) }, + onGetMapApps = { org.scottishtecharmy.soundscape.utils.getMapApps(context) }, + onOpenInMapApp = { app, desc -> + org.scottishtecharmy.soundscape.utils.openInMapApp(context, app, desc) + }, onShareRoute = { routeId -> callbackScope.launch { val route = runCatching { routeDao.getRouteWithMarkers(routeId) }.getOrNull() diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/utils/AndroidMapApps.kt b/app/src/main/java/org/scottishtecharmy/soundscape/utils/AndroidMapApps.kt new file mode 100644 index 000000000..50e2d5c0a --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/utils/AndroidMapApps.kt @@ -0,0 +1,52 @@ +package org.scottishtecharmy.soundscape.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.MapApp + +private const val TAG = "AndroidMapApps" + +private fun geoUri(desc: LocationDescription): Uri { + val lat = formatCoordinate5(desc.location.latitude) + val lon = formatCoordinate5(desc.location.longitude) + // The "(label)" suffix names the pin in Google Maps, OsmAnd, Organic Maps and others; apps + // which don't understand it still use the coordinates. + val label = desc.name.takeIf { it.isNotEmpty() }?.let { "(${Uri.encode(it)})" } ?: "" + return "geo:$lat,$lon?q=$lat,$lon$label".toUri() +} + +/** + * Every installed app which handles geo: links, other than Soundscape itself, sorted by name. + * Needs the geo entry in the manifest or package visibility hides them all. + */ +fun getMapApps(context: Context): List { + val pm = context.packageManager + val probe = Intent(Intent.ACTION_VIEW, "geo:0,0?q=0,0".toUri()) + return pm.queryIntentActivities(probe, PackageManager.MATCH_DEFAULT_ONLY) + .map { it.activityInfo } + .filter { it.packageName != context.packageName } + .distinctBy { it.packageName } + .map { MapApp(id = it.packageName, name = it.applicationInfo.loadLabel(pm).toString()) } + .sortedBy { it.name.lowercase() } +} + +fun openInMapApp(context: Context, app: MapApp, desc: LocationDescription) { + val intent = Intent(Intent.ACTION_VIEW, geoUri(desc)).apply { + setPackage(app.id) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + // Uninstalled between listing and tapping - fall back to letting the system pick. + Log.w(TAG, "${app.id} can't open geo: links, using the chooser", e) + intent.setPackage(null) + context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } +} diff --git a/docs/translation-questions/questions-pl.md b/docs/translation-questions/questions-pl.md index f3172bf13..ec4c2209b 100644 --- a/docs/translation-questions/questions-pl.md +++ b/docs/translation-questions/questions-pl.md @@ -36,7 +36,9 @@ Kilka pojęć, które pojawiają się w pytaniach: coś jest. - **Dźwięk naprowadzający** *(audio beacon)*: gdy wybierzesz cel, w słuchawkach słychać regularny, powtarzający się dźwięk dobiegający z kierunku celu. Gdy się - obrócisz, dźwięk „przesuwa się”, więc do celu można dojść na słuch. + obrócisz, dźwięk „przesuwa się”, więc do celu można dojść na słuch. Po + angielsku to samo słowo *beacon* oznacza też miejsce, na którym dźwięk + ustawiono, i całą funkcję (zob. Q3). - **Znacznik** i **trasa** *(marker, route)*: zapisane miejsca i ich kolejność, przez którą dźwięk naprowadzający prowadzi po kolei. @@ -93,19 +95,39 @@ maszynowe. **Pytanie:** czy to dobre słowo? Jeśli nie, co zamiast niego (np. „komunikat”, „zapowiedź”)? Ważne, żeby miało też naturalny czasownik. -### Q3 — „Dźwięk naprowadzający” *(Audio Beacon)* - -**Kiedy to słychać:** na przyciskach, w ustawieniach i w samouczku, np. „Teraz -możesz usłyszeć dźwięk naprowadzający. Odtwarzany jest z kierunku Twojego celu.” - -**Po angielsku:** „Audio Beacon”. - -**Jak brzmi teraz:** „Dźwięk naprowadzający”. - -**Co budzi wątpliwości:** jest trafny, ale długi jak na coś, co słychać bardzo -często. Termin wybrało tłumaczenie maszynowe. - -**Pytanie:** czy jest coś krótszego, co nadal będzie jasne? +### Q3 — „Dźwięk naprowadzający”: dźwięk, miejsce czy funkcja? *(Audio Beacon: one word for three meanings?)* + +**Kiedy to słychać:** bardzo często. Po angielsku jedno słowo *beacon* oznacza +trzy różne rzeczy, a po polsku prawie wszędzie było „dźwięk naprowadzający”: + +1. **sam dźwięk**: „Audio beacon styles” → „Style dźwięku naprowadzającego”, + „mute the audio beacon” → „wyciszyć dźwięk naprowadzający”; +2. **miejsce, na którym go ustawiono** (to, do czego idziesz): „Distance to the + Audio Beacon” → „Odległość do dźwięku naprowadzającego”, „Beacon is currently + 105 metres away” → „Naprowadzanie jest obecnie w odległości 105 metrów”, + „Beacon Info” → „Informacje o punkcie trasy”, „Call out Beacon” → „Powiadom o + punkcie trasy”; +3. **sama funkcja** (prowadzenie do miejsca): „Beacon set on Dom” → „Ustawiono + naprowadzanie na Dom”, „No beacon active” → „Brak aktywnego dźwięku + naprowadzającego”, tytuł działu pomocy „Beacons and Callouts” → „Dźwięk + naprowadzający i powiadomienia”. + +**Co budzi wątpliwości:** native speaker zwrócił nam uwagę, że „dźwięk +naprowadzający” pasuje tylko do znaczenia 1: nie da się być „w odległości od +dźwięku”. Dlatego nie chcemy już wymuszać jednego słowa wszędzie. Dwie komendy +zmieniono już na „punkt trasy”, ale tego samego słowa używamy dla kolejnych +punktów zapisanej trasy *(waypoint)*, np. „Następny punkt trasy”, a te komendy +działają też wtedy, gdy żadna trasa nie jest włączona. + +**Pytanie:** + +- **a)** jak nazwać **miejsce** (2)? „Punkt trasy” (a punkty na trasie inaczej), + „cel” („Odległość do celu”, „Informacje o celu”), „punkt docelowy”, czy coś + innego? +- **b)** czy „naprowadzanie” pasuje do **funkcji** (3), np. „Brak aktywnego + naprowadzania”, „Naprowadzanie i powiadomienia”? +- **c)** czy „dźwięk naprowadzający” jest dobry dla samego **dźwięku** (1), czy + jest za długi jak na coś, co słychać tak często? ### Q4 — „Ty” czy „Pan/Pani”? *(Register)* @@ -178,7 +200,22 @@ ruchu, więc każde zbędne słowo męczy. **Pytanie:** czy dałoby się je skrócić, nie tracąc jasności? -### Q9 — Coś jeszcze? *(Anything else)* +### Q9 — „Znacznik nawigacyjny” *(Beacon on the map: clashes with Marker)* + +**Kiedy to słychać:** gdy aplikacja ogłasza mijany obiekt z mapy: fizyczny znak +nawigacyjny lub ostrzegawczy w terenie. Nie ma to nic wspólnego z dźwiękiem +naprowadzającym. + +**Po angielsku:** „Beacon”. + +**Jak brzmi teraz:** „Znacznik nawigacyjny”. + +**Co budzi wątpliwości:** „znacznik” to w aplikacji Twoje zapisane miejsce (Q5), +więc brzmi to tak, jakbyś mijał/a swój własny znacznik. + +**Pytanie:** „Znak nawigacyjny”, „Stawa”, czy coś innego? + +### Q10 — Coś jeszcze? *(Anything else)* Jeśli jakieś zdanie brzmi jak tłumaczenie z angielskiego, jest za długie albo niejasne, daj znać. diff --git a/iosApp/project.yml b/iosApp/project.yml index fa968dafb..c9996b2ac 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -102,6 +102,14 @@ targets: SearchProviderURL: "$(SEARCH_PROVIDER_URL)" ExtractProviderURL: "$(EXTRACT_PROVIDER_URL)" LSSupportsOpeningDocumentsInPlace: true + # Map apps Location Details can open a location in - see IosMapApps.kt + LSApplicationQueriesSchemes: + - comgooglemaps + - waze + - citymapper + - moovit + - om + - osmandmaps CFBundleURLTypes: - CFBundleURLName: org.scottishtecharmy.soundscape.geo CFBundleURLSchemes: [ geo ] diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 2b46f504f..e21231e6c 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -176,6 +176,20 @@ share this location with another app start previewing at this location + + Open in Maps App + + Open in %1$s + + view this location in another map or navigation app + + view this location in %1$s + + Choose which app to open with + + Always use this app + + Choose a different app remove this marker from your route diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt index 12e96622f..160354524 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt @@ -26,6 +26,7 @@ import org.scottishtecharmy.soundscape.network.DownloadStateCommon import org.scottishtecharmy.soundscape.preferences.PreferencesProvider import org.scottishtecharmy.soundscape.screens.home.HomeState import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.MapApp import org.scottishtecharmy.soundscape.screens.home.home.AdvancedMarkersAndRoutesSettingsViewModel import org.scottishtecharmy.soundscape.screens.home.offlinemaps.NearbyExtractsState import org.scottishtecharmy.soundscape.screens.home.placesnearby.PlacesNearbyUiState @@ -88,6 +89,12 @@ data class AppCallbacks( val onShareRecording: () -> Unit = {}, val onShareRoute: (routeId: Long) -> Unit = {}, val onShareLocation: (LocationDescription, message: String) -> Unit = { _, _ -> }, + /** + * Lists the installed apps a location can be opened in, and opens one. Location Details + * hides its "Open in Maps App" button while [onGetMapApps] is null. + */ + val onGetMapApps: (() -> List)? = null, + val onOpenInMapApp: (MapApp, LocationDescription) -> Unit = { _, _ -> }, val onRateApp: () -> Unit = {}, val onContactSupport: () -> Unit = {}, val onToggleAudioTour: () -> Unit = {}, diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationItem.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationItem.kt index c19c9a049..b5ec2f117 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationItem.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationItem.kt @@ -55,6 +55,12 @@ data class EnabledFunction( var hint: String = "" ) +/** A screen reader action on a location list item, e.g. "Share". */ +data class LocationItemAction( + val label: String, + val action: (LocationDescription) -> Unit, +) + data class LocationItemDecoration( val location: Boolean = false, val source: LocationSource = LocationSource.UnknownSource, @@ -65,7 +71,9 @@ data class LocationItemDecoration( var reorderable: Boolean = false, var moveUp: (Int) -> Boolean = { false }, var moveDown: (Int) -> Boolean = { false }, - var startPlayback: EnabledFunction = EnabledFunction() + var startPlayback: EnabledFunction = EnabledFunction(), + /** Offered after the move and playback actions, in order. */ + val extraActions: List = emptyList(), ) @Composable @@ -132,29 +140,45 @@ fun LocationItem( action = { false } ) } - if (decoration.reorderable) { - customActions = listOf( - CustomAccessibilityAction( - label = moveUpLabel, - action = { decoration.moveUp(decoration.index) } - ), - CustomAccessibilityAction( - label = moveUpDown, - action = { decoration.moveDown(decoration.index) } - ), - ) - } - if (decoration.startPlayback.enabled) { - customActions = listOf( - CustomAccessibilityAction( - label = startPlaybackLabel, - action = { - decoration.startPlayback.functionLocation(item) - true - } - ), - ) + val actions = buildList { + if (decoration.reorderable) { + add( + CustomAccessibilityAction( + label = moveUpLabel, + action = { decoration.moveUp(decoration.index) } + ) + ) + add( + CustomAccessibilityAction( + label = moveUpDown, + action = { decoration.moveDown(decoration.index) } + ) + ) + } + if (decoration.startPlayback.enabled) { + add( + CustomAccessibilityAction( + label = startPlaybackLabel, + action = { + decoration.startPlayback.functionLocation(item) + true + } + ) + ) + } + decoration.extraActions.forEach { extra -> + add( + CustomAccessibilityAction( + label = extra.label, + action = { + extra.action(item) + true + } + ) + ) + } } + if (actions.isNotEmpty()) customActions = actions }, verticalAlignment = Alignment.CenterVertically ) { diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationListActions.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationListActions.kt new file mode 100644 index 000000000..68e993a1a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationListActions.kt @@ -0,0 +1,40 @@ +package org.scottishtecharmy.soundscape.components + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.scottishtecharmy.soundscape.preferences.PreferencesProvider +import org.scottishtecharmy.soundscape.resources.Res +import org.scottishtecharmy.soundscape.resources.share_title +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.MapApp +import org.scottishtecharmy.soundscape.screens.home.locationDetails.label +import org.scottishtecharmy.soundscape.screens.home.locationDetails.rememberMapAppLauncher + +/** + * What a location list (Places Nearby, Markers) can do with an item besides opening it, offered + * as screen reader actions so a TalkBack or VoiceOver user needn't go via Location Details. + */ +data class LocationListActions( + val mapApps: List = emptyList(), + val preferencesProvider: PreferencesProvider? = null, + val onOpenInMapApp: ((MapApp, LocationDescription) -> Unit)? = null, + val onShare: ((LocationDescription) -> Unit)? = null, +) + +/** The "Open in …" and "Share" actions for [LocationItemDecoration.extraActions]. */ +@Composable +fun rememberLocationItemActions(actions: LocationListActions): List { + val launcher = rememberMapAppLauncher( + actions.mapApps, + actions.preferencesProvider, + actions.onOpenInMapApp, + ) + val openLabel = launcher?.label() + val shareLabel = stringResource(Res.string.share_title) + return buildList { + if (launcher != null && openLabel != null) { + add(LocationItemAction(openLabel) { launcher.open(it) }) + } + actions.onShare?.let { add(LocationItemAction(shareLabel, it)) } + } +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt index efc6236e8..b472f152b 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt @@ -38,7 +38,10 @@ import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.PhotonGeocoder import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.SearchCategories import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.SoundscapeGeocoder import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.TileSearch +import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.formatCoordinate +import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.parseCoordinateSearch import org.scottishtecharmy.soundscape.geoengine.utils.rulers.CheapRuler +import org.scottishtecharmy.soundscape.geoengine.utils.rulers.createCheapRuler import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point @@ -689,14 +692,39 @@ class GeoEngine { suspend fun searchResult(searchString: String): List? { return withContext(org.scottishtecharmy.soundscape.platform.ioDispatcher) { + val userLocation = getCurrentUserGeometry(UserGeometry.HeadingMode.CourseAuto).location + val coordinates = parseCoordinateSearch(searchString, userLocation) + if (coordinates.isNotEmpty()) + return@withContext coordinates.map { coordinateSearchResult(it) } + return@withContext geocoder.getAddressFromLocationName( searchString, - getCurrentUserGeometry(UserGeometry.HeadingMode.CourseAuto).location, + userLocation, localizedStrings ) } } + /** + * A search result for a coordinate that was searched for. It's named by the coordinate itself, + * so that when both ways round of a pair of numbers are listed they can be told apart, and + * described by the address there when the geocoder knows it. + */ + private suspend fun coordinateSearchResult(location: LngLatAlt): LocationDescription { + val geocode = withContext(gridState.treeContext) { + geocoder.getAddressFromLngLat(UserGeometry(location), localizedStrings, false) + } + val address = geocode?.takeIf { + location.createCheapRuler().distance(it.location, location) < + COORDINATE_ADDRESS_RANGE_METERS + }?.name + return LocationDescription( + name = formatCoordinate(location), + location = location, + description = address, + ) + } + fun whatsAroundMe(): TrackedCallout { analytics.logEvent("whatsAroundMe", null) return buildWhatsAroundMeCallout( @@ -893,6 +921,9 @@ class GeoEngine { companion object { private const val TAG = "GeoEngine" + + // How near a searched-for coordinate the geocoded address must be to describe it + private const val COORDINATE_ADDRESS_RANGE_METERS = 200.0 } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearch.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearch.kt new file mode 100644 index 000000000..feef2bf9e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearch.kt @@ -0,0 +1,185 @@ +package org.scottishtecharmy.soundscape.geoengine.utils.geocoders + +import org.scottishtecharmy.soundscape.geoengine.utils.distance +import org.scottishtecharmy.soundscape.geoengine.utils.openlocationcode.OpenLocationCode +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt +import org.scottishtecharmy.soundscape.intents.percentDecode +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.round + +/** + * The locations that search text describes when it is a coordinate rather than a place name, + * nearest to [reference] first. Empty when it isn't a coordinate, and the search is then for a + * name as usual. + * + * Understood are: + * - map links - `geo:55.9486,-4.3148`, and Google, Apple and OpenStreetMap URLs + * - plus codes - `9C7QWMXQ+2V`, or `WMXQ+2V` which is completed near [reference] + * - degrees, minutes and seconds with a hemisphere - `55°56'55.0"N 4°18'53.3"W`, and the + * degrees and decimal minutes of GPS units - `N55 56.917 W4 18.888` + * - a pair of decimal numbers - `55.9486, -4.3148`, or `55,9486 -4,3148` where a comma is the + * decimal separator. Nothing says which of these is the latitude, so both ways round are + * returned where both are valid. + */ +fun parseCoordinateSearch(text: String, reference: LngLatAlt): List { + val trimmed = text.trim() + val candidates = parseLink(trimmed) + ?: parsePlusCode(trimmed, reference) + ?: parseHemispheres(trimmed) + ?: parseNumberPair(trimmed) + ?: return emptyList() + return candidates.sortedBy { + distance(reference.latitude, reference.longitude, it.latitude, it.longitude) + } +} + +/** + * The coordinate as it's shown in search results, latitude first as most maps show it - + * "55.9486, -4.3148". + */ +fun formatCoordinate(location: LngLatAlt): String = + "${formatDegrees(location.latitude)}, ${formatDegrees(location.longitude)}" + +private fun formatDegrees(value: Double): String { + val micro = round(value * 1_000_000).toLong() + val sign = if (micro < 0) "-" else "" + val whole = abs(micro) / 1_000_000 + val fraction = (abs(micro) % 1_000_000).toString().padStart(6, '0').trimEnd('0') + return if (fraction.isEmpty()) "$sign$whole" else "$sign$whole.$fraction" +} + +private fun validLocation(latitude: Double, longitude: Double): LngLatAlt? = + if (abs(latitude) <= 90.0 && abs(longitude) <= 180.0) LngLatAlt(longitude, latitude) else null + +private fun String.toDegrees(): Double? = replace(',', '.').toDoubleOrNull() + +private const val SIGNED_NUMBER = """[+-]?\d+(?:\.\d+)?""" + +// Every one of these puts the latitude first. A marked place comes before the centre of the +// map's view, as a link can have both. +private val linkPatterns = listOf( + Regex("""^geo:(?://)?($SIGNED_NUMBER),\s*($SIGNED_NUMBER)""", RegexOption.IGNORE_CASE), + Regex("""[?&](?:q|query|ll|sll|daddr)=(?:loc:)?($SIGNED_NUMBER),[\s+]*($SIGNED_NUMBER)"""), + Regex("""[?&]mlat=($SIGNED_NUMBER)&mlon=($SIGNED_NUMBER)"""), + Regex("""[?&]center=($SIGNED_NUMBER),[\s+]*($SIGNED_NUMBER)"""), + Regex("""@($SIGNED_NUMBER),($SIGNED_NUMBER)"""), + Regex("""#map=\d+(?:\.\d+)?/($SIGNED_NUMBER)/($SIGNED_NUMBER)"""), +) + +private val linkPrefix = Regex("""^(?:geo:|https?://)""", RegexOption.IGNORE_CASE) + +/** Null when [text] isn't a link, and empty when it's a link with no coordinate in it */ +private fun parseLink(text: String): List? { + if (!linkPrefix.containsMatchIn(text)) return null + // Decoding turns a '+' into a space, which would lose the sign of "+55.9" + val decoded = percentDecode(text.replace("+", "%2B")) + for (pattern in linkPatterns) { + val match = pattern.find(decoded) ?: continue + val latitude = match.groupValues[1].toDoubleOrNull() ?: continue + val longitude = match.groupValues[2].toDoubleOrNull() ?: continue + return listOfNotNull(validLocation(latitude, longitude)) + } + return emptyList() +} + +// A plus code, which may be followed by a locality - "WMXQ+2V Glasgow". The locality is ignored, +// so a short code is always completed near the user. +private val plusCodePattern = + Regex("""^([23456789CFGHJMPQRVWX0]{2,8}\+[23456789CFGHJMPQRVWX]*)(?:[\s,].*)?$""", RegexOption.IGNORE_CASE) + +private fun parsePlusCode(text: String, reference: LngLatAlt): List? { + val code = plusCodePattern.find(text)?.groupValues?.get(1)?.uppercase() ?: return null + if (!OpenLocationCode.isValidCode(code)) return null + val fullCode = when { + OpenLocationCode.isFullCode(code) -> code + // A short code is missing at least its first four characters, and has at least two after + // the '+', which keeps "22+" and the like out + OpenLocationCode.isShortCode(code) && + code.indexOf('+') >= 4 && code.length - code.indexOf('+') > 2 -> + OpenLocationCode.recover(code, reference.latitude, reference.longitude) + else -> return null + } + val area = OpenLocationCode.decode(fullCode) + return listOfNotNull(validLocation(area.centerLatitude, area.centerLongitude)) +} + +private const val UNSIGNED_NUMBER = """\d+(?:[.,]\d+)?""" + +// Degrees, then optionally minutes and seconds. The unit symbols can be left out, as GPS units +// often do - "55 56.917". +private const val ANGLE = + """($UNSIGNED_NUMBER)\s*°?\s*(?:($UNSIGNED_NUMBER)\s*'?\s*)?(?:($UNSIGNED_NUMBER)\s*"?)?""" + +// The hemisphere letters either all come before the numbers or all after them +private val hemispheresBefore = + Regex("""^([NSEW])\s*$ANGLE\s*[,;]?\s*([NSEW])\s*$ANGLE$""", RegexOption.IGNORE_CASE) +private val hemispheresAfter = + Regex("""^$ANGLE\s*([NSEW])\s*[,;]?\s*$ANGLE\s*([NSEW])$""", RegexOption.IGNORE_CASE) + +private fun normalizeSymbols(text: String) = text + .replace('º', '°') + .replace(Regex("[′’‘´`]"), "'") + .replace(Regex("[″”“]|''"), "\"") + +private fun parseHemispheres(text: String): List? { + val normalized = normalizeSymbols(text) + val components = hemispheresBefore.find(normalized)?.groupValues?.let { groups -> + listOf( + groups[1] to groups.subList(2, 5), + groups[5] to groups.subList(6, 9), + ) + } ?: hemispheresAfter.find(normalized)?.groupValues?.let { groups -> + listOf( + groups[4] to groups.subList(1, 4), + groups[8] to groups.subList(5, 8), + ) + } ?: return null + + var latitude: Double? = null + var longitude: Double? = null + for ((hemisphere, angle) in components) { + val degrees = angleToDegrees(angle) ?: return null + when (hemisphere.uppercase()) { + "N" -> if (latitude == null) latitude = degrees else return null + "S" -> if (latitude == null) latitude = -degrees else return null + "E" -> if (longitude == null) longitude = degrees else return null + "W" -> if (longitude == null) longitude = -degrees else return null + } + } + return listOfNotNull(validLocation(latitude ?: return null, longitude ?: return null)) +} + +/** Degrees from [parts] of degrees, minutes and seconds, the last two of which may be empty */ +private fun angleToDegrees(parts: List): Double? { + val (degreesText, minutesText, secondsText) = parts + val degrees = degreesText.toDegrees() ?: return null + if (minutesText.isEmpty()) return if (secondsText.isEmpty()) degrees else null + // Only the last of the parts can have a fraction - "55.5° 30'" makes no sense + if (degrees != floor(degrees)) return null + val minutes = minutesText.toDegrees() ?: return null + if (minutes >= 60.0) return null + if (secondsText.isEmpty()) return degrees + minutes / 60.0 + if (minutes != floor(minutes)) return null + val seconds = secondsText.toDegrees() ?: return null + if (seconds >= 60.0) return null + return degrees + minutes / 60.0 + seconds / 3600.0 +} + +// Both numbers need a decimal part, so that "12 34" or a house number isn't taken for a +// coordinate. Where the decimal separator is a comma, the numbers can't also be separated by +// just a comma - "55,9,4,3" could be read more than one way. +private val decimalPointPair = + Regex("""^([+-]?\d+\.\d+)°?\s*[,;\s]\s*([+-]?\d+\.\d+)°?$""") +private val decimalCommaPair = + Regex("""^([+-]?\d+,\d+)°?(?:\s*;\s*|\s+)([+-]?\d+,\d+)°?$""") + +private fun parseNumberPair(text: String): List? { + val match = decimalPointPair.find(text) ?: decimalCommaPair.find(text) ?: return null + val first = match.groupValues[1].toDegrees() ?: return null + val second = match.groupValues[2].toDegrees() ?: return null + return listOfNotNull( + validLocation(first, second), + validLocation(second, first), + ).distinct() +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/CodeArea.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/CodeArea.kt new file mode 100644 index 000000000..7000308bb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/CodeArea.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Vendored from https://github.com/aughtone/aughtone-openlocationcode 0.0.1-alpha2 + * (a Kotlin port of https://github.com/google/open-location-code). Modified: package renamed. + */ + +package org.scottishtecharmy.soundscape.geoengine.utils.openlocationcode + +/** + * Coordinates of a decoded Open Location Code. + * + * The coordinates include the latitude and longitude of the lower left and upper right corners + * and the center of the bounding box for the area the code represents. + */ +data class CodeArea( + val southLatitude: Double, + val westLongitude: Double, + val northLatitude: Double, + val eastLongitude: Double, + val length: Int +) { + val latitudeHeight: Double + get() = northLatitude - southLatitude + + val longitudeWidth: Double + get() = eastLongitude - westLongitude + + val centerLatitude: Double + get() = (southLatitude + northLatitude) / 2.0 + + val centerLongitude: Double + get() = (westLongitude + eastLongitude) / 2.0 +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/OpenLocationCode.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/OpenLocationCode.kt new file mode 100644 index 000000000..f44ba3c93 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/OpenLocationCode.kt @@ -0,0 +1,486 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Vendored from https://github.com/aughtone/aughtone-openlocationcode 0.0.1-alpha2 + * (a Kotlin port of https://github.com/google/open-location-code). Modified: package renamed. + */ + +package org.scottishtecharmy.soundscape.geoengine.utils.openlocationcode + +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Core Open Location Code (Plus Codes) encoding and decoding algorithms. + */ +object OpenLocationCode { + + // Provides a normal precision code, approximately 14x14 meters. + const val CODE_PRECISION_NORMAL = 10 + + // The character set used to encode the values. + const val CODE_ALPHABET = "23456789CFGHJMPQRVWX" + + // A separator used to break the code into two parts to aid memorability. + const val SEPARATOR = '+' + + // The character used to pad codes. + const val PADDING_CHARACTER = '0' + + // The number of characters to place before the separator. + private const val SEPARATOR_POSITION = 8 + + // The minimum number of digits in a Plus Code. + const val MIN_DIGIT_COUNT = 2 + + // The max number of digits to process in a Plus Code. + const val MAX_DIGIT_COUNT = 15 + + // Maximum code length using just lat/lng pair encoding. + private const val PAIR_CODE_LENGTH = 10 + + // Number of digits in the grid coding section. + private const val GRID_CODE_LENGTH = MAX_DIGIT_COUNT - PAIR_CODE_LENGTH + + // The base to use to convert numbers to/from. + private val ENCODING_BASE = CODE_ALPHABET.length + + // The maximum value for latitude in degrees. + private const val LATITUDE_MAX = 90L + + // The maximum value for longitude in degrees. + private const val LONGITUDE_MAX = 180L + + // Number of columns in the grid refinement method. + private const val GRID_COLUMNS = 4 + + // Number of rows in the grid refinement method. + private const val GRID_ROWS = 5 + + // Value to multiple latitude degrees to convert it to an integer with the maximum encoding + // precision. I.e. ENCODING_BASE**3 * GRID_ROWS**GRID_CODE_LENGTH + private const val LAT_INTEGER_MULTIPLIER = 8000L * 3125L + + // Value to multiple longitude degrees to convert it to an integer with the maximum encoding + // precision. I.e. ENCODING_BASE**3 * GRID_COLUMNS**GRID_CODE_LENGTH + private const val LNG_INTEGER_MULTIPLIER = 8000L * 1024L + + // Value of the most significant latitude digit after it has been converted to an integer. + private val LAT_MSP_VALUE = LAT_INTEGER_MULTIPLIER * ENCODING_BASE * ENCODING_BASE + + // Value of the most significant longitude digit after it has been converted to an integer. + private val LNG_MSP_VALUE = LNG_INTEGER_MULTIPLIER * ENCODING_BASE * ENCODING_BASE + + /** + * Encodes latitude/longitude into a 10-digit Open Location Code. + * + * @param latitude The latitude in decimal degrees. + * @param longitude The longitude in decimal degrees. + * @return The code. + */ + fun encode(latitude: Double, longitude: Double): String { + return encode(latitude, longitude, CODE_PRECISION_NORMAL) + } + + /** + * Encodes latitude/longitude into an Open Location Code of the provided length. + * + * @param latitude The latitude in decimal degrees. + * @param longitude The longitude in decimal degrees. + * @param codeLength The number of digits in the returned code. + * @return The code. + */ + fun encode(latitude: Double, longitude: Double, codeLength: Int): String { + val integers = degreesToIntegers(latitude, longitude) + return encodeIntegers(integers[0], integers[1], codeLength) + } + + internal fun encodeIntegers(latOriginal: Long, lngOriginal: Long, codeLengthOriginal: Int): String { + var lat = latOriginal + var lng = lngOriginal + var codeLength = codeLengthOriginal + // Limit the maximum number of digits in the code. + codeLength = min(codeLength, MAX_DIGIT_COUNT) + // Check that the code length requested is valid. + require(!(codeLength < PAIR_CODE_LENGTH && codeLength % 2 == 1 || codeLength < MIN_DIGIT_COUNT)) { + "Illegal code length $codeLength" + } + + // Store the code - we build it in reverse and reorder it afterwards. + val revCodeBuilder = StringBuilder() + // Compute the grid part of the code if necessary. + if (codeLength > PAIR_CODE_LENGTH) { + for (i in 0 until GRID_CODE_LENGTH) { + val latDigit = lat % GRID_ROWS + val lngDigit = lng % GRID_COLUMNS + val ndx = (latDigit * GRID_COLUMNS + lngDigit).toInt() + revCodeBuilder.append(CODE_ALPHABET[ndx]) + lat /= GRID_ROWS + lng /= GRID_COLUMNS + } + } else { + // Note: The reference Java implementation uses Math.pow() here which yields double precision. + // When translating `lat / Math.pow(GRID_ROWS, GRID_CODE_LENGTH)` to Kotlin KMP, floating point + // differences could occasionally yield values like 2477599.999999999 which truncate incorrectly toLong(). + // We substitute `Math.pow` with explicit integer arithmetic to guarantee 100% precision. + var latDivisor = 1L + var lngDivisor = 1L + for (i in 0 until GRID_CODE_LENGTH) { + latDivisor *= GRID_ROWS + lngDivisor *= GRID_COLUMNS + } + lat /= latDivisor + lng /= lngDivisor + } + // Compute the pair section of the code. + for (i in 0 until PAIR_CODE_LENGTH / 2) { + revCodeBuilder.append(CODE_ALPHABET[(lng % ENCODING_BASE).toInt()]) + revCodeBuilder.append(CODE_ALPHABET[(lat % ENCODING_BASE).toInt()]) + lat /= ENCODING_BASE + lng /= ENCODING_BASE + // If we are at the separator position, add the separator. + if (i == 0) { + revCodeBuilder.append(SEPARATOR) + } + } + // Reverse the code. + val codeBuilder = revCodeBuilder.reverse() + + // If we need to pad the code, replace some of the digits. + if (codeLength < SEPARATOR_POSITION) { + for (i in codeLength until SEPARATOR_POSITION) { + codeBuilder[i] = PADDING_CHARACTER + } + } + return codeBuilder.substring(0, max(SEPARATOR_POSITION + 1, codeLength + 1)) + } + + /** + * Decodes an Open Location Code into a CodeArea object encapsulating + * latitude/longitude bounding box. + * + * @param code Open Location Code to be decoded. + * @return A CodeArea object. + * @throws IllegalArgumentException if the provided code is not a valid Open Location Code. + */ + fun decode(code: String): CodeArea { + val uppercaseCode = code.uppercase() + require(isValidCode(uppercaseCode)) { + "The provided code '$code' is not a valid Open Location Code." + } + check(isFullCode(uppercaseCode)) { + "Method decode() could only be called on valid full codes, code was $code." + } + // Strip padding and separator characters out of the code. + val clean = uppercaseCode.replace(SEPARATOR.toString(), "").replace(PADDING_CHARACTER.toString(), "") + + // Initialise the values. We work them out as integers and convert them to doubles at the end. + var latVal = -LATITUDE_MAX * LAT_INTEGER_MULTIPLIER + var lngVal = -LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER + // Define the place value for the digits. We'll divide this down as we work through the code. + var latPlaceVal = LAT_MSP_VALUE + var lngPlaceVal = LNG_MSP_VALUE + + var i = 0 + val limit = min(clean.length, PAIR_CODE_LENGTH) + while (i < limit) { + latPlaceVal /= ENCODING_BASE + lngPlaceVal /= ENCODING_BASE + latVal += CODE_ALPHABET.indexOf(clean[i]) * latPlaceVal + lngVal += CODE_ALPHABET.indexOf(clean[i + 1]) * lngPlaceVal + i += 2 + } + + for (j in PAIR_CODE_LENGTH until min(clean.length, MAX_DIGIT_COUNT)) { + latPlaceVal /= GRID_ROWS + lngPlaceVal /= GRID_COLUMNS + val digit = CODE_ALPHABET.indexOf(clean[j]) + val row = digit / GRID_COLUMNS + val col = digit % GRID_COLUMNS + latVal += row * latPlaceVal + lngVal += col * lngPlaceVal + } + + val latitudeLo = latVal.toDouble() / LAT_INTEGER_MULTIPLIER + val longitudeLo = lngVal.toDouble() / LNG_INTEGER_MULTIPLIER + val latitudeHi = (latVal + latPlaceVal).toDouble() / LAT_INTEGER_MULTIPLIER + val longitudeHi = (lngVal + lngPlaceVal).toDouble() / LNG_INTEGER_MULTIPLIER + + return CodeArea( + latitudeLo, + longitudeLo, + latitudeHi, + longitudeHi, + min(clean.length, MAX_DIGIT_COUNT) + ) + } + + /** + * Returns whether the provided string is a valid Open Location code. + * + * @param code The code to check. + * @return True if it is a valid full or short code. + */ + fun isValidCode(code: String?): Boolean { + if (code == null || code.length < 2) { + return false + } + val uppercaseCode = code.uppercase() + + // There must be exactly one separator. + val separatorPosition = uppercaseCode.indexOf(SEPARATOR) + if (separatorPosition == -1) { + return false + } + if (separatorPosition != uppercaseCode.lastIndexOf(SEPARATOR)) { + return false + } + // There must be an even number of at most 8 characters before the separator. + if (separatorPosition % 2 != 0 || separatorPosition > SEPARATOR_POSITION) { + return false + } + + // Check first two characters: only some values from the alphabet are permitted. + if (separatorPosition == SEPARATOR_POSITION) { + // First latitude character can only have first 9 values. + if (CODE_ALPHABET.indexOf(uppercaseCode[0]) > 8) { + return false + } + + // First longitude character can only have first 18 values. + if (CODE_ALPHABET.indexOf(uppercaseCode[1]) > 17) { + return false + } + } + + // Check the characters before the separator. + var paddingStarted = false + for (i in 0 until separatorPosition) { + if (CODE_ALPHABET.indexOf(uppercaseCode[i]) == -1 && uppercaseCode[i] != PADDING_CHARACTER) { + // Invalid character. + return false + } + if (paddingStarted) { + // Once padding starts, there must not be anything but padding. + if (uppercaseCode[i] != PADDING_CHARACTER) { + return false + } + } else if (uppercaseCode[i] == PADDING_CHARACTER) { + paddingStarted = true + // Short codes cannot have padding + if (separatorPosition < SEPARATOR_POSITION) { + return false + } + // Padding can start on even character: 2, 4 or 6. + if (i != 2 && i != 4 && i != 6) { + return false + } + } + } + + // Check the characters after the separator. + if (uppercaseCode.length > separatorPosition + 1) { + if (paddingStarted) { + return false + } + // Only one character after separator is forbidden. + if (uppercaseCode.length == separatorPosition + 2) { + return false + } + for (i in separatorPosition + 1 until uppercaseCode.length) { + if (CODE_ALPHABET.indexOf(uppercaseCode[i]) == -1) { + return false + } + } + } + + return true + } + + /** + * Returns whether the provided Open Location Code is a full Open Location Code. + */ + fun isFullCode(code: String): Boolean { + if (!isValidCode(code)) return false + val uppercaseCode = code.uppercase() + return uppercaseCode.indexOf(SEPARATOR) == SEPARATOR_POSITION + } + + /** + * Returns whether the provided Open Location Code is a short Open Location Code. + */ + fun isShortCode(code: String): Boolean { + if (!isValidCode(code)) return false + val uppercaseCode = code.uppercase() + val index = uppercaseCode.indexOf(SEPARATOR) + return index in 0..= 0 + } + + /** + * Returns short Open Location Code from the full Open Location Code created by removing + * four or six digits, depending on the provided reference point. + * + * @param code The full code to shorten. + * @param referenceLatitude Degrees. + * @param referenceLongitude Degrees. + * @return A short code if possible. + */ + fun shorten(code: String, referenceLatitude: Double, referenceLongitude: Double): String { + val uppercaseCode = code.uppercase() + require(isValidCode(uppercaseCode)) { "Not a valid code: $code" } + check(isFullCode(uppercaseCode)) { "shorten() method could only be called on a full code." } + check(!isPadded(uppercaseCode)) { "shorten() method can not be called on a padded code." } + + val codeArea = decode(uppercaseCode) + val range = max( + abs(referenceLatitude - codeArea.centerLatitude), + abs(referenceLongitude - codeArea.centerLongitude) + ) + // We are going to check to see if we can remove three pairs, two pairs or just one pair of + // digits from the code. + for (i in 4 downTo 1) { + // Check if we're close enough to shorten. The range must be less than 1/2 + // the precision to shorten at all, and we want to allow some safety, so + // use 0.3 instead of 0.5 as a multiplier. + if (range < computeLatitudePrecision(i * 2) * 0.3) { + // We're done. + return uppercaseCode.substring(i * 2) + } + } + throw IllegalArgumentException("Reference location is too far from the Open Location Code center.") + } + + /** + * Returns a full Open Location Code from a short Open Location Code, given the reference location. + * + * @param code The short code to recover. + * @param referenceLat Degrees. + * @param referenceLng Degrees. + * @return The nearest matching full code. + */ + fun recover(code: String, referenceLat: Double, referenceLng: Double): String { + val uppercaseCode = code.uppercase() + require(isValidCode(uppercaseCode)) { "Not a valid code: $code" } + if (isFullCode(uppercaseCode)) { + return uppercaseCode + } + val referenceLatitude = clipLatitude(referenceLat) + val referenceLongitude = normalizeLongitude(referenceLng) + + val digitsToRecover = SEPARATOR_POSITION - uppercaseCode.indexOf(SEPARATOR) + // The precision (height and width) of the missing prefix in degrees. + val prefixPrecision = ENCODING_BASE.toDouble().pow(2 - digitsToRecover / 2) + + // Use the reference location to generate the prefix. + val recoveredPrefix = encode(referenceLatitude, referenceLongitude).substring(0, digitsToRecover) + + // Combine the prefix with the short code and decode it. + val recovered = recoveredPrefix + uppercaseCode + val recoveredCodeArea = decode(recovered) + + // Work out whether the new code area is too far from the reference location. If it is, we + // move it. It can only be out by a single precision step. + var recoveredLatitude = recoveredCodeArea.centerLatitude + var recoveredLongitude = recoveredCodeArea.centerLongitude + + // Move the recovered latitude by one precision up or down if it is too far from the reference, + // unless doing so would lead to an invalid latitude. + val latitudeDiff = recoveredLatitude - referenceLatitude + if (latitudeDiff > prefixPrecision / 2 && recoveredLatitude - prefixPrecision > -LATITUDE_MAX) { + recoveredLatitude -= prefixPrecision + } else if (latitudeDiff < -prefixPrecision / 2 && recoveredLatitude + prefixPrecision < LATITUDE_MAX) { + recoveredLatitude += prefixPrecision + } + + // Move the recovered longitude by one precision up or down if it is too far from the + // reference. + val longitudeDiff = recoveredCodeArea.centerLongitude - referenceLongitude + if (longitudeDiff > prefixPrecision / 2) { + recoveredLongitude -= prefixPrecision + } else if (longitudeDiff < -prefixPrecision / 2) { + recoveredLongitude += prefixPrecision + } + + return encode(recoveredLatitude, recoveredLongitude, recovered.length - 1) + } + + // Private static methods. + + /** + * Convert latitude and longitude in degrees into the integer values needed for reliable encoding. + * (To avoid floating point precision errors.) + */ + private fun degreesToIntegers(latitude: Double, longitude: Double): LongArray { + var lat = floor(latitude * LAT_INTEGER_MULTIPLIER).toLong() + var lng = floor(longitude * LNG_INTEGER_MULTIPLIER).toLong() + + // Clip and normalise values. + lat += LATITUDE_MAX * LAT_INTEGER_MULTIPLIER + if (lat < 0) { + lat = 0 + } else if (lat >= 2 * LATITUDE_MAX * LAT_INTEGER_MULTIPLIER) { + lat = 2 * LATITUDE_MAX * LAT_INTEGER_MULTIPLIER - 1 + } + + lng += LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER + if (lng < 0) { + lng = lng % (2 * LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER) + 2 * LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER + } else if (lng >= 2 * LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER) { + lng %= 2 * LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER + } + return longArrayOf(lat, lng) + } + + private fun clipLatitude(latitude: Double): Double { + return min(max(latitude, -LATITUDE_MAX.toDouble()), LATITUDE_MAX.toDouble()) + } + + private fun normalizeLongitude(longitude: Double): Double { + if (longitude >= -LONGITUDE_MAX && longitude < LONGITUDE_MAX) { + // longitude is within proper range, no normalization necessary + return longitude + } + + // % in Java/Kotlin uses truncated division with the remainder having the same sign as + // the dividend. For any input longitude < -360, the result of longitude%CIRCLE_DEG + // will still be negative but > -360, so we need to add 360 and apply % a second time. + val circleDeg = 2 * LONGITUDE_MAX // 360 degrees + return (longitude % circleDeg + circleDeg + LONGITUDE_MAX) % circleDeg - LONGITUDE_MAX + } + + /** + * Compute the latitude precision value for a given code length. Lengths <= 10 have the same + * precision for latitude and longitude, but lengths > 10 have different precisions due to the + * grid method having fewer columns than rows. Copied from the JS implementation. + */ + private fun computeLatitudePrecision(codeLength: Int): Double { + if (codeLength <= CODE_PRECISION_NORMAL) { + return ENCODING_BASE.toDouble().pow(codeLength / -2 + 2) + } + return ENCODING_BASE.toDouble().pow(-3.0) / GRID_ROWS.toDouble().pow(codeLength - PAIR_CODE_LENGTH) + } +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/PlusCode.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/PlusCode.kt new file mode 100644 index 000000000..b176bd4d8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/PlusCode.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Vendored from https://github.com/aughtone/aughtone-openlocationcode 0.0.1-alpha2 + * (a Kotlin port of https://github.com/google/open-location-code). Modified: package renamed. + */ + +package org.scottishtecharmy.soundscape.geoengine.utils.openlocationcode + +import kotlin.jvm.JvmInline + +/** + * An idiomatic value class wrapper for Open Location Codes (Plus Codes). + * + * Plus Codes are short, ~10 character codes that can be used instead of street addresses. + * The codes can be generated and decoded offline, and use a reduced character set that + * minimises the chance of codes including words. + */ +@JvmInline +value class PlusCode(val value: String) { + + init { + require(OpenLocationCode.isValidCode(value.uppercase())) { + "The provided code '$value' is not a valid Open Location Code." + } + } + + /** + * Returns whether this is a full Open Location Code. + */ + val isFull: Boolean + get() = OpenLocationCode.isFullCode(value) + + /** + * Returns whether this is a short Open Location Code. + */ + val isShort: Boolean + get() = OpenLocationCode.isShortCode(value) + + /** + * Returns whether this is a padded Open Location Code, meaning that it contains less than 8 valid digits. + */ + val isPadded: Boolean + get() = OpenLocationCode.isPadded(value) + + /** + * Decodes this Plus Code into a CodeArea encapsulating the latitude/longitude bounding box. + * @throws IllegalStateException if the code is not a full code. + */ + fun decode(): CodeArea = OpenLocationCode.decode(value) + + /** + * Returns a short Plus Code from this full Plus Code created by removing digits, + * depending on the provided reference point. + * + * @param referenceLatitude Degrees. + * @param referenceLongitude Degrees. + * @return A shortened PlusCode. + * @throws IllegalStateException if the code is padded or not full. + */ + fun shorten(referenceLatitude: Double, referenceLongitude: Double): PlusCode { + return PlusCode(OpenLocationCode.shorten(value, referenceLatitude, referenceLongitude)) + } + + /** + * Returns a full Plus Code from this short Plus Code, given the reference location. + * + * @param referenceLatitude Degrees. + * @param referenceLongitude Degrees. + * @return The nearest matching full PlusCode. + */ + fun recover(referenceLatitude: Double, referenceLongitude: Double): PlusCode { + return PlusCode(OpenLocationCode.recover(value, referenceLatitude, referenceLongitude)) + } + + /** + * Returns whether the bounding box specified by the Open Location Code contains the provided point. + * + * @param latitude Degrees. + * @param longitude Degrees. + * @return True if the coordinates are contained by the code. + */ + fun contains(latitude: Double, longitude: Double): Boolean { + val area = decode() + return latitude in area.southLatitude.. { return result } -private fun percentDecode(input: String): String { +internal fun percentDecode(input: String): String { if ('%' !in input && '+' !in input) return input val out = StringBuilder() val bytes = mutableListOf() diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt index 7dcc7162c..14f87814f 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/navigation/SharedNavGraph.kt @@ -1,5 +1,6 @@ package org.scottishtecharmy.soundscape.navigation +import org.scottishtecharmy.soundscape.components.LocationListActions import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -266,6 +267,7 @@ fun SharedNavHost( onStartBeacon = { desc -> holder.startBeacon(desc.location, desc.name) }, + itemActions = rememberLocationListActions(callbacks, preferencesProvider), ) } else { // Fallback path: external state holder publishes via flows. @@ -293,6 +295,7 @@ fun SharedNavHost( desc.name ) }, + itemActions = rememberLocationListActions(callbacks, preferencesProvider), ) } } @@ -302,6 +305,7 @@ fun SharedNavHost( flows = flows, callbacks = callbacks, audioTour = audioTour, + itemActions = rememberLocationListActions(callbacks, preferencesProvider), onBack = { navController.popBackStack() }, onAddRoute = { navController.navigate(SharedRoutes.ADD_ROUTE) }, onSelectMarker = { desc -> @@ -356,6 +360,9 @@ fun SharedNavHost( onShareLocation = { sharedDesc -> callbacks.onShareLocation(sharedDesc, shareMessage) }, + // Queried each time the screen is entered so newly installed apps show up. + mapApps = remember { callbacks.onGetMapApps?.invoke() ?: emptyList() }, + onOpenInMapApp = callbacks.onOpenInMapApp.takeIf { callbacks.onGetMapApps != null }, onOfflineMaps = { locationDesc -> navStateHolder.navigateWithOfflineMapsTarget( navController, SharedRoutes.OFFLINE_MAPS, locationDesc.location, @@ -696,6 +703,25 @@ fun SharedNavHost( } } +/** + * Open in map app and Share, offered as screen reader actions on Places Nearby and Markers items. + * The map apps are queried each time a list is entered so newly installed apps show up. + */ +@Composable +private fun rememberLocationListActions( + callbacks: AppCallbacks, + preferencesProvider: PreferencesProvider?, +): LocationListActions { + val shareMessage = stringResource(Res.string.universal_links_marker_share_message) + val mapApps = remember { callbacks.onGetMapApps?.invoke() ?: emptyList() } + return LocationListActions( + mapApps = mapApps, + preferencesProvider = preferencesProvider, + onOpenInMapApp = callbacks.onOpenInMapApp.takeIf { callbacks.onGetMapApps != null }, + onShare = { desc -> callbacks.onShareLocation(desc, shareMessage) }, + ) +} + internal object MarkersAndRoutesTabMemory { var selected: Int = 1 } @@ -705,6 +731,7 @@ private fun MarkersAndRoutesContainer( flows: AppFlows, callbacks: AppCallbacks, audioTour: AudioTour? = null, + itemActions: LocationListActions = LocationListActions(), onBack: () -> Unit, onAddRoute: () -> Unit = {}, onSelectMarker: (LocationDescription) -> Unit = {}, @@ -764,6 +791,7 @@ private fun MarkersAndRoutesContainer( userLocation = userLocation, onSelectItem = { onSelectMarker(it) }, onStartBeacon = { loc, name -> holder.startBeacon(loc, name) }, + itemActions = itemActions, ) } else { val uiState by flows.markersUiState?.collectAsState() @@ -777,6 +805,7 @@ private fun MarkersAndRoutesContainer( onStartBeacon = { loc, name -> callbacks.onStartBeacon(loc.latitude, loc.longitude, name) }, + itemActions = itemActions, ) } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferenceComposable.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferenceComposable.kt index 613fff841..2fd601ac8 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferenceComposable.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferenceComposable.kt @@ -59,3 +59,28 @@ fun rememberBooleanPreferenceState( } } } + +/** + * Read-only reactive view of a string preference; the string counterpart of + * [rememberBooleanPreference]. + */ +@Composable +fun rememberStringPreference( + provider: PreferencesProvider?, + key: String, + default: String, +): State { + val state = remember(provider, key) { + mutableStateOf(provider?.getString(key, default) ?: default) + } + DisposableEffect(provider, key) { + val listener = PreferencesListener { changed -> + if (changed == key) { + state.value = provider?.getString(key, default) ?: default + } + } + provider?.addListener(listener) + onDispose { provider?.removeListener(listener) } + } + return state +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferencesProvider.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferencesProvider.kt index 1e42c7faf..4c937d2d1 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferencesProvider.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/preferences/PreferencesProvider.kt @@ -97,6 +97,12 @@ object PreferenceKeys { * throwing, and so that it round-trips through the plain string preference store. */ const val POI_RANK_STRATEGY = "PoiRankStrategy" + + /** + * Identifier of the map app the user chose to always open locations in from Location + * Details - an Android package name, or an iOS app key from IosMapApps. Empty means ask. + */ + const val PREFERRED_MAP_APP = "PreferredMapApp" } object PreferenceDefaults { @@ -127,4 +133,5 @@ object PreferenceDefaults { const val MARKERS_SORT_ASCENDING = true const val HEAD_TRACKING_ENABLED = false const val POI_RANK_STRATEGY = "off" + const val PREFERRED_MAP_APP = "" } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/OpenInMapApp.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/OpenInMapApp.kt new file mode 100644 index 000000000..7e6de5975 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/OpenInMapApp.kt @@ -0,0 +1,254 @@ +package org.scottishtecharmy.soundscape.screens.home.locationDetails + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.CollectionItemInfo +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.collectionItemInfo +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import org.jetbrains.compose.resources.stringResource +import org.scottishtecharmy.soundscape.platform.isIos +import org.scottishtecharmy.soundscape.preferences.PreferenceDefaults +import org.scottishtecharmy.soundscape.preferences.PreferenceKeys +import org.scottishtecharmy.soundscape.preferences.PreferencesProvider +import org.scottishtecharmy.soundscape.preferences.rememberStringPreference +import org.scottishtecharmy.soundscape.resources.Res +import org.scottishtecharmy.soundscape.resources.general_alert_cancel +import org.scottishtecharmy.soundscape.resources.location_detail_action_open_in_app +import org.scottishtecharmy.soundscape.resources.location_detail_action_open_in_app_hint +import org.scottishtecharmy.soundscape.resources.location_detail_action_open_in_named_app +import org.scottishtecharmy.soundscape.resources.location_detail_action_open_in_named_app_hint +import org.scottishtecharmy.soundscape.resources.open_in_app_change +import org.scottishtecharmy.soundscape.resources.open_in_app_dialog_title +import org.scottishtecharmy.soundscape.resources.open_in_app_remember +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.markers_routes.components.IconWithTextButton +import org.scottishtecharmy.soundscape.ui.theme.spacing + +/** + * An installed app which can show a location on a map. + * + * @param id stable identifier stored in [PreferenceKeys.PREFERRED_MAP_APP]: the package name on + * Android, a key from IosMapApps on iOS. + * @param name the app's user-visible name. + */ +data class MapApp(val id: String, val name: String) + +/** + * Opens locations in the user's map app: straight into the remembered one if there is one, + * otherwise via a chooser which can remember the pick. Shared by Location Details' button and + * the "Open in" accessibility action on Places Nearby and Markers list items. + */ +class MapAppLauncher internal constructor( + /** The remembered app, if it is still installed. */ + val preferred: MapApp?, + private val mapApps: List, + private val onOpenInMapApp: (MapApp, LocationDescription) -> Unit, + private val showChooser: (LocationDescription) -> Unit, +) { + /** Opens [desc] in the remembered app, or asks which app when there's a choice to make. */ + fun open(desc: LocationDescription) { + when { + preferred != null -> onOpenInMapApp(preferred, desc) + // Nothing to choose between, so don't make the user dismiss a one-item list. + mapApps.size == 1 -> onOpenInMapApp(mapApps.first(), desc) + else -> showChooser(desc) + } + } + + /** Always asks, so the remembered app can be changed. */ + fun choose(desc: LocationDescription) = showChooser(desc) +} + +/** + * Remembers a [MapAppLauncher], or null when there are no map apps to open in. The chooser + * dialog is part of the caller's composition, so it stays up only while the caller does. + */ +@Composable +fun rememberMapAppLauncher( + mapApps: List, + preferencesProvider: PreferencesProvider?, + onOpenInMapApp: ((MapApp, LocationDescription) -> Unit)?, +): MapAppLauncher? { + if (mapApps.isEmpty() || onOpenInMapApp == null) return null + + val preferredId by rememberStringPreference( + preferencesProvider, + PreferenceKeys.PREFERRED_MAP_APP, + PreferenceDefaults.PREFERRED_MAP_APP, + ) + // A remembered app which has since been uninstalled is ignored until the user picks again. + val preferred = mapApps.firstOrNull { it.id == preferredId } + var choosingFor by remember { mutableStateOf(null) } + + choosingFor?.let { desc -> + MapAppChooserDialog( + mapApps = mapApps, + initiallyRemember = preferred != null, + onDismiss = { choosingFor = null }, + onChosen = { app, remember -> + choosingFor = null + preferencesProvider?.putString( + PreferenceKeys.PREFERRED_MAP_APP, + if (remember) app.id else PreferenceDefaults.PREFERRED_MAP_APP, + ) + onOpenInMapApp(app, desc) + }, + ) + } + return remember(preferred, mapApps, onOpenInMapApp) { + MapAppLauncher(preferred, mapApps, onOpenInMapApp) { choosingFor = it } + } +} + +/** "Open in Waze" once an app is remembered, "Open in Maps App" until then. */ +@Composable +fun MapAppLauncher.label(): String = + preferred?.let { stringResource(Res.string.location_detail_action_open_in_named_app, it.name) } + ?: stringResource(Res.string.location_detail_action_open_in_app) + +/** + * "Open in Maps App" button for Location Details. Once an app is remembered the button opens it + * directly and its label names it. A long press brings the chooser back; TalkBack offers that as + * "double tap and hold", and on iOS it is a VoiceOver custom action instead. + */ +@Composable +internal fun OpenInMapAppButton( + locationDescription: LocationDescription, + launcher: MapAppLauncher, + modifier: Modifier = Modifier, +) { + val preferred = launcher.preferred + val changeLabel = stringResource(Res.string.open_in_app_change) + IconWithTextButton( + icon = Icons.AutoMirrored.Filled.OpenInNew, + text = launcher.label(), + talkbackHint = if (preferred != null) { + stringResource(Res.string.location_detail_action_open_in_named_app_hint, preferred.name) + } else { + stringResource(Res.string.location_detail_action_open_in_app_hint) + }, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .defaultMinSize(minHeight = spacing.targetSize) + .fillMaxWidth() + .then( + if (preferred != null && isIos) { + Modifier.semantics { + customActions = listOf( + CustomAccessibilityAction(changeLabel) { + launcher.choose(locationDescription) + true + }, + ) + } + } else Modifier, + ), + buttonTestTag = "locationDetailsOpenInMapApp", + onLongClick = if (preferred != null) ({ launcher.choose(locationDescription) }) else null, + onLongClickLabel = changeLabel, + ) { + launcher.open(locationDescription) + } +} + +@Composable +private fun MapAppChooserDialog( + mapApps: List, + initiallyRemember: Boolean, + onDismiss: () -> Unit, + onChosen: (MapApp, remember: Boolean) -> Unit, +) { + var rememberChoice by remember { mutableStateOf(initiallyRemember) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.open_in_app_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(spacing.none)) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .semantics { + collectionInfo = CollectionInfo(rowCount = mapApps.size, columnCount = 1) + }, + ) { + mapApps.forEachIndexed { index, app -> + Text( + text = app.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = spacing.targetSize) + .clickable(role = Role.Button) { onChosen(app, rememberChoice) } + .semantics { + collectionItemInfo = CollectionItemInfo( + rowIndex = index, + rowSpan = 1, + columnIndex = 0, + columnSpan = 1, + ) + } + .padding(vertical = spacing.small) + .testTag("mapAppChoice_${app.id}"), + ) + } + } + HorizontalDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = spacing.targetSize) + .toggleable( + value = rememberChoice, + role = Role.Checkbox, + onValueChange = { rememberChoice = it }, + ) + .testTag("mapAppRememberChoice"), + ) { + Checkbox(checked = rememberChoice, onCheckedChange = null) + Text( + text = stringResource(Res.string.open_in_app_remember), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = spacing.small), + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.general_alert_cancel)) + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/SharedLocationDetailsScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/SharedLocationDetailsScreen.kt index 86d38e885..e031ac20e 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/SharedLocationDetailsScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/SharedLocationDetailsScreen.kt @@ -81,6 +81,9 @@ fun SharedLocationDetailsScreen( onDeleteMarker: ((Long) -> Unit)? = null, onEnableStreetPreview: ((LngLatAlt) -> Unit)? = null, onShareLocation: ((LocationDescription) -> Unit)? = null, + /** Installed apps the location can be opened in; the button is hidden when empty. */ + mapApps: List = emptyList(), + onOpenInMapApp: ((MapApp, LocationDescription) -> Unit)? = null, onOfflineMaps: ((LocationDescription) -> Unit)? = null, /** * Offline-geocodes the location to a full address, used to fill in an address for a place @@ -147,6 +150,9 @@ fun SharedLocationDetailsScreen( onEditMarker = onEditMarker, onEnableStreetPreview = onEnableStreetPreview, onShareLocation = onShareLocation, + mapApps = mapApps, + preferencesProvider = preferencesProvider, + onOpenInMapApp = onOpenInMapApp, onOfflineMaps = onOfflineMaps, ) @@ -275,6 +281,9 @@ private fun LocationDescriptionButtonsSection( onEditMarker: ((LocationDescription) -> Unit)?, onEnableStreetPreview: ((LngLatAlt) -> Unit)?, onShareLocation: ((LocationDescription) -> Unit)?, + mapApps: List, + preferencesProvider: PreferencesProvider?, + onOpenInMapApp: ((MapApp, LocationDescription) -> Unit)?, onOfflineMaps: ((LocationDescription) -> Unit)?, ) { Column( @@ -362,6 +371,11 @@ private fun LocationDescriptionButtonsSection( } } + // Open in another map app + rememberMapAppLauncher(mapApps, preferencesProvider, onOpenInMapApp)?.let { launcher -> + OpenInMapAppButton(locationDescription = locationDescription, launcher = launcher) + } + // Offline maps if (onOfflineMaps != null) { IconWithTextButton( 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 0dcb0f8e1..f6f3aee3b 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 @@ -15,6 +15,8 @@ import org.scottishtecharmy.soundscape.components.EnabledFunction import org.scottishtecharmy.soundscape.components.FolderItem import org.scottishtecharmy.soundscape.components.LocationItem import org.scottishtecharmy.soundscape.components.LocationItemDecoration +import org.scottishtecharmy.soundscape.components.LocationListActions +import org.scottishtecharmy.soundscape.components.rememberLocationItemActions import org.scottishtecharmy.soundscape.i18n.ComposeLocalizedStrings import org.scottishtecharmy.soundscape.resources.Res import org.scottishtecharmy.soundscape.resources.location_detail_action_beacon_hint @@ -30,7 +32,9 @@ fun PlacesNearbyList( onClickFolder: (String, String) -> Unit, onStartBeacon: (LocationDescription) -> Unit, modifier: Modifier, + itemActions: LocationListActions = LocationListActions(), ) { + val extraActions = rememberLocationItemActions(itemActions) val localizedStrings = remember { ComposeLocalizedStrings() } val locations = remember(uiState) { filterLocations(uiState, localizedStrings) @@ -84,6 +88,7 @@ fun PlacesNearbyList( functionLocation = onStartBeacon, hint = stringResource(Res.string.location_detail_action_beacon_hint) ), + extraActions = extraActions, ), userLocation = uiState.userLocation, modifier = Modifier.testTag("placesNearby-$index") diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyScreen.kt index 2d184517c..18c10722a 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/placesnearby/PlacesNearbyScreen.kt @@ -1,5 +1,6 @@ package org.scottishtecharmy.soundscape.screens.home.placesnearby +import org.scottishtecharmy.soundscape.components.LocationListActions import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -25,6 +26,7 @@ fun PlacesNearbyScreen( onClickFolder: (String, String) -> Unit = { _, _ -> }, onClickBack: () -> Unit = {}, onStartBeacon: (LocationDescription) -> Unit = {}, + itemActions: LocationListActions = LocationListActions(), ) { // System back (button, gesture, or predictive-back swipe) must drill up a folder level @@ -63,6 +65,7 @@ fun PlacesNearbyScreen( onSelectItem = onSelectItem, onClickFolder = onClickFolder, onStartBeacon = onStartBeacon, + itemActions = itemActions, modifier = modifier.padding(innerPadding) ) } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/components/IconWithTextButton.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/components/IconWithTextButton.kt index 8958e0115..8ae707fcc 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/components/IconWithTextButton.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/components/IconWithTextButton.kt @@ -1,6 +1,8 @@ package org.scottishtecharmy.soundscape.screens.markers_routes.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -33,7 +35,12 @@ import org.scottishtecharmy.soundscape.ui.theme.tinyPadding * announcement, a route's first waypoint, street preview's callouts. Tells VoiceOver to stay * quiet for it instead of speaking the label and its activation click over the top. See * [StartsSpeechControl]. + * @param onLongClick optional secondary action on a long press. TalkBack offers it as "double tap + * and hold to [onLongClickLabel]"; VoiceOver has no equivalent, so on iOS pair it with a + * semantics custom action passed via [modifier]. + * @param onLongClickLabel what the long press does, read out by TalkBack. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun IconWithTextButton( modifier: Modifier = Modifier, @@ -49,12 +56,25 @@ fun IconWithTextButton( color: Color = MaterialTheme.colorScheme.onSurface, buttonTestTag: String? = null, startsSpeech: Boolean = false, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, onClick: () -> Unit ) { val button: @Composable (Modifier) -> Unit = { buttonModifier -> Row( modifier = buttonModifier - .clickable(role = Role.Button) { onClick() } + .then( + if (onLongClick != null) { + Modifier.combinedClickable( + role = Role.Button, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + onClick = onClick, + ) + } else { + Modifier.clickable(role = Role.Button) { onClick() } + } + ) .tinyPadding() .talkbackHint(talkbackHint) .then(if (buttonTestTag != null) Modifier.testTag(buttonTestTag) else Modifier), diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/MarkersAndRoutesList.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/MarkersAndRoutesList.kt index 574e584b6..b74e2d87a 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/MarkersAndRoutesList.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/MarkersAndRoutesList.kt @@ -10,6 +10,8 @@ import org.jetbrains.compose.resources.stringResource import org.scottishtecharmy.soundscape.components.EnabledFunction import org.scottishtecharmy.soundscape.components.LocationItem import org.scottishtecharmy.soundscape.components.LocationItemDecoration +import org.scottishtecharmy.soundscape.components.LocationListActions +import org.scottishtecharmy.soundscape.components.rememberLocationItemActions import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.resources.Res import org.scottishtecharmy.soundscape.resources.location_detail_action_beacon_from_markers @@ -24,8 +26,10 @@ fun MarkersAndRoutesList( modifier: Modifier = Modifier, onSelect: (LocationDescription) -> Unit, onStartPlayback: (LocationDescription) -> Unit = {}, - onStartBeacon: (LocationDescription) -> Unit = {} + onStartBeacon: (LocationDescription) -> Unit = {}, + itemActions: LocationListActions = LocationListActions(), ) { + val extraActions = rememberLocationItemActions(itemActions) val startBeaconHint = stringResource(Res.string.location_detail_action_beacon_from_markers) val startRouteHint = stringResource(Res.string.route_detail_action_start_route_hint) @@ -46,7 +50,8 @@ fun MarkersAndRoutesList( enabled = true, functionLocation = if (uiState.markers) onStartBeacon else onStartPlayback, hint = if (uiState.markers) startBeaconHint else startRouteHint - ) + ), + extraActions = extraActions, ), userLocation = userLocation ) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/markersscreen/MarkersScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/markersscreen/MarkersScreen.kt index 695b4adc8..e594edd00 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/markersscreen/MarkersScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/markers_routes/screens/markersscreen/MarkersScreen.kt @@ -1,5 +1,6 @@ package org.scottishtecharmy.soundscape.screens.markers_routes.screens.markersscreen +import org.scottishtecharmy.soundscape.components.LocationListActions import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,7 +39,8 @@ fun MarkersScreen( userLocation: LngLatAlt?, onSelectItem: (LocationDescription) -> Unit, onShowError: (String) -> Unit = {}, - onStartBeacon: (LngLatAlt, String) -> Unit = { _, _ -> } + onStartBeacon: (LngLatAlt, String) -> Unit = { _, _ -> }, + itemActions: LocationListActions = LocationListActions(), ) { Column( modifier = @@ -128,7 +130,8 @@ fun MarkersScreen( onSelect = onSelectItem, onStartBeacon = { desc -> onStartBeacon(desc.location, desc.name) - } + }, + itemActions = itemActions, ) } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationText.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationText.kt index 40c23a610..ca6ff4ead 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationText.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationText.kt @@ -42,20 +42,19 @@ fun buildShareLocationText( return placeholderPattern.replace(messageTemplate) { match -> replacements.getValue(match.value) } } -/** Format a double to exactly 5 decimal places, no locale-specific separators. */ -private fun formatCoordinate5(value: Double): String { - val scaled = kotlin.math.round(value * 100000.0) / 100000.0 - val asString = scaled.toString() - val dot = asString.indexOf('.') - return when { - dot < 0 -> "$asString.00000" - asString.length - dot - 1 >= 5 -> asString.substring(0, dot + 6) - else -> asString + "0".repeat(5 - (asString.length - dot - 1)) - } +/** + * Format a double to exactly 5 decimal places, no locale-specific separators. Built from integer + * parts because Double.toString switches to exponent form below 1e-3 ("-1.0E-4"), which a + * coordinate near the equator or the Greenwich meridian can hit. + */ +fun formatCoordinate5(value: Double): String { + val scaled = kotlin.math.round(kotlin.math.abs(value) * 100000.0).toLong() + val sign = if (value < 0 && scaled != 0L) "-" else "" + return "$sign${scaled / 100000}.${(scaled % 100000).toString().padStart(5, '0')}" } /** RFC 3986 percent-encoding of UTF-8 bytes; encodes spaces as `%20`. */ -private fun urlEncodeUtf8(value: String): String { +internal fun urlEncodeUtf8(value: String): String { val bytes = value.encodeToByteArray() val builder = StringBuilder(bytes.size) for (b in bytes) { diff --git a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearchTest.kt b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearchTest.kt new file mode 100644 index 000000000..dfbb61530 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearchTest.kt @@ -0,0 +1,219 @@ +package org.scottishtecharmy.soundscape.geoengine.utils.geocoders + +import org.scottishtecharmy.soundscape.geoengine.utils.openlocationcode.OpenLocationCode +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoordinateSearchTest { + + private val glasgow = LngLatAlt(-4.2518, 55.8642) + + private fun parse(text: String, reference: LngLatAlt = glasgow) = + parseCoordinateSearch(text, reference) + + private fun assertLocations( + expected: List>, + actual: List, + tolerance: Double = 1e-6, + ) { + assertEquals(expected.size, actual.size, "Got $actual") + for ((index, latLon) in expected.withIndex()) { + val (latitude, longitude) = latLon + assertTrue( + abs(actual[index].latitude - latitude) < tolerance && + abs(actual[index].longitude - longitude) < tolerance, + "Expected $latitude,$longitude at $index, got $actual" + ) + } + } + + private fun assertSingle(latitude: Double, longitude: Double, text: String) = + assertLocations(listOf(latitude to longitude), parse(text), tolerance = 1e-5) + + private fun assertNotCoordinate(text: String) = + assertTrue(parse(text).isEmpty(), "\"$text\" was taken as ${parse(text)}") + + // Plain pairs of numbers + + @Test + fun pairIsTriedBothWaysRoundNearestFirst() { + val expected = listOf(55.9486 to -4.3148, -4.3148 to 55.9486) + assertLocations(expected, parse("55.9486, -4.3148")) + assertLocations(expected, parse("-4.3148 55.9486")) + assertLocations(expected, parse("55.9486,-4.3148")) + assertLocations(expected, parse("55.9486; -4.3148")) + assertLocations(expected, parse(" +55.9486°, -4.3148° ")) + } + + @Test + fun nearestOfTheTwoComesFirst() { + // Somewhere near 4.3N 55.9E, off the Horn of Africa + val reference = LngLatAlt(55.0, 4.0) + assertLocations( + listOf(-4.3148 to 55.9486, 55.9486 to -4.3148), + parse("55.9486, -4.3148", reference) + ) + } + + @Test + fun onlyValidOrderingIsReturned() { + assertLocations(listOf(4.2 to 95.1), parse("95.1, 4.2")) + assertNotCoordinate("95.1 120.0") + assertNotCoordinate("200.5 10.5") + } + + @Test + fun equalNumbersGiveOneResult() { + assertLocations(listOf(45.5 to 45.5), parse("45.5, 45.5")) + } + + @Test + fun commaDecimalSeparator() { + val expected = listOf(55.9486 to -4.3148, -4.3148 to 55.9486) + assertLocations(expected, parse("55,9486 -4,3148")) + assertLocations(expected, parse("55,9486; -4,3148")) + assertNotCoordinate("55,9,4,3") + } + + @Test + fun numbersWithoutDecimalsAreNotCoordinates() { + assertNotCoordinate("12 34") + assertNotCoordinate("55, 4") + assertNotCoordinate("55.9 4") + assertNotCoordinate("Main St 5.5") + assertNotCoordinate("pharmacy") + assertNotCoordinate("") + } + + // Hemispheres + + @Test + fun degreesMinutesSeconds() { + assertSingle(50.0, 50.0, "50°00'00.0\"N 50°00'00.0\"E") + assertSingle(55.948611, -4.314806, "55°56'55.0\"N 4°18'53.3\"W") + assertSingle(-33.856944, 151.215278, "33°51'25\"S 151°12'55\"E") + } + + @Test + fun typographicSymbols() { + assertSingle(55.948611, -4.314806, "55°56′55.0″N 4°18′53.3″W") + assertSingle(55.948611, -4.314806, "55°56’55.0”N 4°18’53.3”W") + assertSingle(55.948611, -4.314806, "55°56'55.0''N 4°18'53.3''W") + assertSingle(55.948611, -4.314806, "55º56'55.0\"N 4º18'53.3\"W") + } + + @Test + fun longitudeFirst() { + assertSingle(55.948611, -4.314806, "4°18'53.3\"W 55°56'55.0\"N") + } + + @Test + fun hemisphereBeforeTheNumbers() { + assertSingle(55.948611, -4.314806, "N 55°56'55.0\" W 4°18'53.3\"") + assertSingle(55.948611, -4.314806, "n55°56'55.0\", w4°18'53.3\"") + } + + @Test + fun decimalDegreesWithHemisphere() { + assertSingle(55.9486, -4.3148, "55.9486° N, 4.3148° W") + assertSingle(55.9486, -4.3148, "55.9486N 4.3148W") + assertSingle(55.9486, -4.3148, "55,9486° N; 4,3148° W") + } + + @Test + fun degreesAndDecimalMinutes() { + assertSingle(55.948617, -4.3148, "N 55° 56.917' W 004° 18.888'") + assertSingle(55.948617, -4.3148, "N55 56.917 W4 18.888") + assertSingle(55.948617, -4.3148, "55 56.917 N 4 18.888 W") + } + + @Test + fun invalidHemispheres() { + assertNotCoordinate("50°N 60°N") + assertNotCoordinate("50°E 60°W") + assertNotCoordinate("50°61'N 4°W") + assertNotCoordinate("50°10'60\"N 4°W") + assertNotCoordinate("91°N 0°E") + assertNotCoordinate("50.5°30'N 4°W") + assertNotCoordinate("North Street") + } + + // Links + + @Test + fun geoLinks() { + assertSingle(55.9486, -4.3148, "geo:55.9486,-4.3148") + assertSingle(55.9486, -4.3148, "geo://55.9486,-4.3148") + assertSingle(55.9486, -4.3148, "geo:55.9486,-4.3148?z=17") + assertSingle(55.9486, -4.3148, "GEO:55.9486,-4.3148") + } + + @Test + fun googleLinks() { + assertSingle(55.9486, -4.3148, "https://maps.google.com/?q=55.9486,-4.3148") + assertSingle(55.9486, -4.3148, "https://www.google.com/maps?q=55.9486%2C-4.3148") + assertSingle(55.9486, -4.3148, "https://www.google.com/maps/search/?api=1&query=55.9486,+-4.3148") + assertSingle(55.9486, -4.3148, "https://www.google.com/maps/@55.9486,-4.3148,17z") + assertSingle(55.9486, -4.3148, "https://www.google.com/maps/place/Somewhere/@55.9486,-4.3148,17z/data=!3m1") + } + + @Test + fun appleLinks() { + assertSingle(55.9486, -4.3148, "https://maps.apple.com/?ll=55.9486,-4.3148&q=Pin") + assertSingle(55.9486, -4.3148, "https://maps.apple.com/?q=55.9486,-4.3148") + } + + @Test + fun openStreetMapLinks() { + assertSingle(55.9486, -4.3148, "https://www.openstreetmap.org/#map=17/55.9486/-4.3148") + assertSingle(55.9486, -4.3148, "https://www.openstreetmap.org/?mlat=55.9486&mlon=-4.3148#map=17/55.9/-4.3") + } + + @Test + fun linkWithoutCoordinates() { + assertNotCoordinate("https://www.example.com/") + assertNotCoordinate("https://maps.google.com/?q=Glasgow+Central") + } + + // Plus codes + + private val milngavie = LngLatAlt(-4.3148, 55.9486) + + @Test + fun fullPlusCode() { + val code = OpenLocationCode.encode(milngavie.latitude, milngavie.longitude) + val area = OpenLocationCode.decode(code) + assertSingle(area.centerLatitude, area.centerLongitude, code) + assertSingle(area.centerLatitude, area.centerLongitude, code.lowercase()) + // Within the ~14m square of a 10 digit code + assertLocations(listOf(milngavie.latitude to milngavie.longitude), parse(code), 1.5e-4) + } + + @Test + fun shortPlusCodeIsCompletedNearTheUser() { + val code = OpenLocationCode.encode(milngavie.latitude, milngavie.longitude) + val area = OpenLocationCode.decode(code) + val short = code.substring(4) + assertSingle(area.centerLatitude, area.centerLongitude, short) + assertSingle(area.centerLatitude, area.centerLongitude, "$short Milngavie") + assertSingle(area.centerLatitude, area.centerLongitude, "$short, Milngavie, UK") + } + + @Test + fun invalidPlusCodes() { + assertNotCoordinate("22+") + assertNotCoordinate("ABCD+EF") + assertNotCoordinate("9C7QWMXQ+2V+3") + assertNotCoordinate("C++") + } + + @Test + fun formattedCoordinate() { + assertEquals("55.9486, -4.3148", formatCoordinate(LngLatAlt(-4.3148, 55.9486))) + assertEquals("-0.000001, 0", formatCoordinate(LngLatAlt(0.0, -0.000001))) + assertEquals("50, 50.123457", formatCoordinate(LngLatAlt(50.1234567, 50.0))) + } +} diff --git a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationTextTest.kt b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationTextTest.kt index 5389b3b63..1ace6468d 100644 --- a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationTextTest.kt +++ b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/utils/ShareLocationTextTest.kt @@ -177,4 +177,16 @@ class ShareLocationTextTest { // The literal name text stays untouched; only the real template placeholder resolves. assertEquals("%2\$s - $actualUrl", result) } + + @Test + fun formatCoordinate5_neverUsesExponentForm() { + // Double.toString gives "-1.0E-4" here; a coordinate near the Greenwich meridian or + // the equator must still come out as plain decimal. + assertEquals("-0.00010", formatCoordinate5(-0.0001)) + assertEquals("0.00005", formatCoordinate5(0.00005)) + assertEquals("0.00000", formatCoordinate5(-0.000001)) + assertEquals("55.86421", formatCoordinate5(55.864213)) + assertEquals("-4.25180", formatCoordinate5(-4.2518)) + assertEquals("180.00000", formatCoordinate5(180.0)) + } } diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosMapApps.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosMapApps.kt new file mode 100644 index 000000000..094bd8ee1 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosMapApps.kt @@ -0,0 +1,65 @@ +package org.scottishtecharmy.soundscape + +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.MapApp +import org.scottishtecharmy.soundscape.utils.formatCoordinate5 +import org.scottishtecharmy.soundscape.utils.urlEncodeUtf8 +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +/** + * The map apps Location Details can open a location in. iOS has no equivalent of Android's + * geo: intent resolution, so each app is a known URL scheme probed with canOpenURL - every + * [probeScheme] must also be listed under LSApplicationQueriesSchemes in project.yml, or + * canOpenURL always says no. + * + * [MapApp.id] is [key], which is what gets remembered, so keys must never change. + */ +private enum class IosMapApp( + val key: String, + val displayName: String, + /** Null for apps which are always available. */ + val probeScheme: String?, + val url: (lat: String, lon: String, name: String) -> String, +) { + APPLE_MAPS("apple", "Apple Maps", null, { lat, lon, name -> + "https://maps.apple.com/?ll=$lat,$lon&q=${name.ifEmpty { "$lat,$lon" }}" + }), + GOOGLE_MAPS("google", "Google Maps", "comgooglemaps", { lat, lon, _ -> + "comgooglemaps://?q=$lat,$lon¢er=$lat,$lon&zoom=17" + }), + WAZE("waze", "Waze", "waze", { lat, lon, _ -> + "waze://?ll=$lat,$lon&navigate=no" + }), + CITYMAPPER("citymapper", "Citymapper", "citymapper", { lat, lon, name -> + "citymapper://directions?endcoord=$lat,$lon&endname=$name" + }), + MOOVIT("moovit", "Moovit", "moovit", { lat, lon, name -> + "moovit://directions?dest_lat=$lat&dest_lon=$lon&dest_name=$name" + }), + ORGANIC_MAPS("organicmaps", "Organic Maps", "om", { lat, lon, name -> + "om://map?v=1&ll=$lat,$lon&n=$name" + }), + OSMAND("osmand", "OsmAnd", "osmandmaps", { lat, lon, name -> + "osmandmaps://?lat=$lat&lon=$lon&z=17&title=$name" + }), +} + +internal fun getIosMapApps(): List = + IosMapApp.entries + .filter { app -> + val scheme = app.probeScheme ?: return@filter true + val url = NSURL.URLWithString("$scheme://") ?: return@filter false + UIApplication.sharedApplication.canOpenURL(url) + } + .map { MapApp(id = it.key, name = it.displayName) } + +internal fun openInIosMapApp(app: MapApp, desc: LocationDescription) { + val mapApp = IosMapApp.entries.firstOrNull { it.key == app.id } ?: IosMapApp.APPLE_MAPS + val urlString = mapApp.url( + formatCoordinate5(desc.location.latitude), + formatCoordinate5(desc.location.longitude), + urlEncodeUtf8(desc.name), + ) + NSURL.URLWithString(urlString)?.let { openExternalUrl(it) } +} diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt index e5899635a..151e1a0be 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt @@ -296,6 +296,8 @@ fun MainViewController() = ComposeUIViewController { ), ) }, + onGetMapApps = { getIosMapApps() }, + onOpenInMapApp = { app, desc -> openInIosMapApp(app, desc) }, onRateApp = { val url = NSURL.URLWithString("https://apps.apple.com/app/id6459021379?action=write-review") diff --git a/translations/guidance/_common.md b/translations/guidance/_common.md index c07755e08..ed006d0e0 100644 --- a/translations/guidance/_common.md +++ b/translations/guidance/_common.md @@ -255,6 +255,10 @@ Two things still make a term wrong regardless of origin: - **a split corpus**: two different words for one concept (bn ঘোষণা/কলআউট, ur اعلان/کالآؤٹ, zh 提示/播报, ja コールアウト/読み上げ) +"One concept" means one *meaning*. **Beacon** is several meanings under one +English word, and splitting it by meaning is correct, not a split corpus +(see C19). + ## C13 — Accessibility hints serve two platforms; fix the iOS template, not the hints Every `*_hint` / `*_acc_hint` fragment goes to both platforms: @@ -405,3 +409,43 @@ backslash before a quote, where Weblate stores a real newline and `\"`. Convert before uploading. Applied 2026-09-25: all 19 restored, uploaded and verified live. + +## C19 — "Beacon" names three things; don't force one word on all of them + +English uses *beacon* for three meanings, and many languages can't stretch +one word over all of them: + +| Meaning | English examples | What the translation must name | +|---|---|---| +| **Sound**: the audio signal | "Audio beacon styles", "mute the beacon", "the beacon gets quieter" | a sound | +| **Place**: the location the beacon is on | "Distance to the Audio Beacon", "Beacon is currently %1$s away", "Call out Beacon", "Beacon Info" | a place or target (you can be a distance *from* it) | +| **Feature**: guiding someone to a place | "set a beacon on an address", "Start/Stop Beacon", "No beacon active" | the act or mode of guiding | + +A term that names the sound («dźwięk naprowadzający», "guiding sound") turns +"Distance to the Audio Beacon" into "distance to the guiding sound". That is +nonsense when spoken, and this app is mostly heard. + +> **Case (2026-09-29, pl):** A Polish user reported that one fixed term +> broke the place and feature strings. They warned that any pass enforcing +> "Beacon → one term" (a glossary, translation memory, AI normalisation) +> would bring the problem back. Their words: *"one domain object does not +> necessarily imply one user-facing noun in every language."* + +**How to apply:** + +- Before translating or reviewing a beacon string, decide which of the three + meanings it is. The translator comment says *"A 'beacon' is an audio + signal…"* on about 20 FAQ strings, including ones where the meaning is + the place. Don't let that comment decide it. +- A language may use one word for all three **if it works for all three**. + This rule doesn't require a split. It forbids *forcing* one where a + reviewer has chosen to split. +- Never "fix" a beacon string to match another beacon string's wording + without first checking that both have the same meaning (C8). Deliberate + variation looks exactly like drift. +- A glossary row for Beacon in `.md` lists one term **per meaning**, + not one term. +- Don't leave "beacon" in English as a workaround. A local speech + synthesiser will mispronounce it (unlike C12's loanwords, which the phone + itself already uses). + diff --git a/translations/guidance/pl.md b/translations/guidance/pl.md index 5d2ad09b8..71e70c4e7 100644 --- a/translations/guidance/pl.md +++ b/translations/guidance/pl.md @@ -4,7 +4,7 @@ |---|---| | Weblate component | `androidkmp` | | Corpus at last sweep | 1522 units (2026-09-24) | -| Last native-speaker input | **none yet** | +| Last native-speaker input | 2026-09-29: written feedback on *beacon* (PL-B1), plus two Weblate edits by `trc695`, probably the same person | | Reporter platform | — | Read with [`_common.md`](_common.md). @@ -26,7 +26,7 @@ replacement *wording* stays `unconfirmed`. A review pack was prepared for the first reviewer: - `docs/translation-questions/questions-pl.md` — the numbered open questions below - (Q1…Q9), in the same layout as every other language (converted 2026-09-25). + (Q1…Q10), in the same layout as every other language (converted 2026-09-25). - `translations/review/pl-full/07-nowe-teksty.md` — the per-text review of the 27 strings added 2026-09-23, which was the published sheet until 2026-09-25. - `translations/review/pl-full/` — the whole corpus split by area, plus @@ -40,7 +40,9 @@ A review pack was prepared for the first reviewer: | English | Polish | Status | Why | |---|---|---|---| | Callout | powiadomienie | `unconfirmed` | Shipping since the first pass. Collides conceptually with system notifications — see Q2 | -| Audio Beacon | dźwięk naprowadzający | `unconfirmed` | Accurate but long for something spoken often — see Q3 | +| Beacon: **sound** | dźwięk naprowadzający | `agreed` (meaning) / `unconfirmed` (word) | Only for the audio signal. Never for the place or the feature — see PL-B1, C19, Q3c | +| Beacon: **place/target** | *open*: «punkt trasy» (reporter's Weblate edit), «cel», or «punkt docelowy» | `unconfirmed` | «punkt trasy» is also our Waypoint term — see PL-B1 and Q3a | +| Beacon: **feature** | naprowadzanie | `unconfirmed` | Already used in ~15 strings (`action_beacon_started`, `siri_*`, `route_beacon_progress`). No speaker has endorsed it by name. See Q3b | | Marker | znacznik | `unconfirmed` | `markers_title` says «Znaczniki (pinezki)» — see PL-I1 | | Waypoint | punkt trasy | `unconfirmed` | Not a calque of our own "route point" gloss (rule C1), but no speaker has confirmed it is what Polish mapping apps use | | Landmarks | punkty orientacyjne | `unconfirmed` | Consistent with `callouts_places_and_landmarks` | @@ -82,6 +84,79 @@ if the noun changes, the genitive changes with it. > OpenStreetMap, in the nominative, giving «Droga do ulica Główna». Same unresolved > question as Ukrainian's. +### PL-B1 — *Beacon* is three meanings; Polish needs a word for each (`agreed`; words `unconfirmed`) + +**Source:** written feedback from a Polish user, 2026-09-29, pasted into the +session. It argues the principle in general terms and proposes **no +replacement words**. The same day (02:20–02:45), Weblate user `trc695` joined +the project and changed exactly two strings: +`menu_beacon_info` → «Informacje o punkcie trasy» and +`beacon_action_callout_beacon` → «Powiadom o punkcie trasy». Almost certainly +the reporter. These are their only edits to date. + +The reporter's point, in their words: + +> *"There is no single Polish term that can safely represent all of these +> meanings without colliding with other existing navigation concepts such as +> marker/pin, waypoint, POI, destination, or audio guidance."* +> +> *"previously reviewed translations should ideally be preserved … A glossary +> rule that globally maps beacon to one Polish term would likely introduce +> errors."* + +**Decision (`agreed`):** Polish keeps separate words for the three meanings +in C19. «dźwięk naprowadzający» stays for the **sound**. It is wrong for +the **place** ("distance to the guiding sound") and awkward for the +**feature**. This file previously had one glossary row for Beacon, which +made every review pass push towards one term. That row is replaced. + +**Words (`unconfirmed`):** the place-word is the open question. The +reporter chose «punkt trasy», but this file already uses that for +**Waypoint**, and they themselves listed *waypoint* as a concept the +beacon must not collide with. When no route is running, the beacon is not +on a waypoint. So their edit is kept (C8: never revert a native speaker +silently) and asked about (Q3a), not swept. + +**Sweep result (80 units mention beacon; full list in +`/tmp/weblate-review/pl-findings.json`):** + +- **Sound, correct as is (~45):** styles, mute/unmute and their hints, + first-launch, the tour, the FAQ answers about volume and holding the phone + flat, `microsoft_copyright`, *"beacon sounds"* in the assistant help. Guard + these. A pass that turns them into «naprowadzanie» is also wrong. +- **Place, wrong word (5):** `callouts_audio_beacon`, + `callouts_audio_beacon_description`, `callouts_audio_beacon_distance` + (currently «Naprowadzanie jest obecnie w odległości…»: the *feature* is + not a distance away), plus the reporter's two «punkt trasy» edits, + pending Q3a. `route_beacon_progress` «Naprowadzanie na %1$s» names the + place explicitly and is fine. +- **Feature, sound-word where the feature is meant (3):** + `callouts_no_beacon_active`, `settings_help_section_beacons_and_pois`, + `help_text_destination_beacons_how_2` («usunąć … dźwięk naprowadzający»). + Inventory only, because «naprowadzanie» is unconfirmed. +- **Moving the beacon to the next waypoint** (`route_detail_action_*_hint`, + `routes_no_routes_hint_2`, `help_text_routes_content_what`, + `help_text_remote_control_how`): «przenieść dźwięk naprowadzający do + następnego punktu trasy». The sound really does move, so this reads + correctly. Left alone. +- **Set a beacon on X** (FAQ questions, «ustawić dźwięk naprowadzający na + adres»): acceptable Polish either way, and the corpus already mixes in + «ustawić naprowadzanie na». Per the reporter, that mix is not something + to normalise. +- **Button label `location_detail_action_beacon`** «Uruchom dźwięk + naprowadzający»: pressing it does start the sound, so it is fine. Five + help strings quote it verbatim, so it should not change casually. + +**Also found in the sweep (one-off errors, `agreed`):** +`help_text_destination_beacons_when` «naprowadzania dzwiękowego» (missing +ź); `help_text_routes_content_what` «a Dźwięk naprowadzający» (capital +mid-sentence). + +**Separate: `osm_beacon`** (the OSM map feature, a physical navigation +beacon, not ours) is «Znacznik nawigacyjny». «znacznik» is our **Marker**, +so a map beacon is announced as if it were a saved marker. «Znak +nawigacyjny» or «Stawa» would avoid that (`unconfirmed`, Q9). + ### PL-R1 — Informal second person throughout (`unconfirmed`) The corpus addresses the user as «ty» — «możesz», «twoje trasy», imperatives @@ -126,9 +201,16 @@ normalising eventually, but it is invisible to a listener and low priority. ## Rejected -Nothing yet. Once the first reviewer turns something down, record it here **with -the evidence that made it attractive** — otherwise the next pass reinstates it -(rule C8). +- **One Polish word for every *beacon* string** (the old single glossary row, + «dźwięk naprowadzający»). Attractive because it looks consistent, and + C12's "split corpus" test would have flagged the variation as drift. + Rejected 2026-09-29 on native-speaker feedback (PL-B1, C19). +- **Leaving "beacon" in English as a neutral fallback.** Attractive because + it avoids choosing. Rejected by the reporter: Polish speech synthesis + mispronounces it, and the word is heard more often than it is read. + +Once a reviewer turns something else down, record it here **with the evidence +that made it attractive**. Otherwise the next pass reinstates it (rule C8). --- @@ -142,8 +224,16 @@ Q1, Q2, Q3, Q5 and Q8 here), so map a reply by the pack it cites. 2. **Is «powiadomienie» right for *callout*?** On a phone the word means a system notification. If it misleads, what replaces it, with a natural verb (rule C3)? (AI-only term, asked for confirmation) -3. **Is «dźwięk naprowadzający» too long for *beacon*?** (AI-only term, asked for - confirmation) +3. **Beacon has three meanings: what is each called?** (PL-B1, C19) + (a) the **place**: «punkt trasy» (the reporter's Weblate edit, but it + collides with Waypoint and those commands work with no route running), + «cel», «punkt docelowy», or something else? This also decides + `callouts_audio_beacon`, `callouts_audio_beacon_distance`, `menu_beacon_info` + and `beacon_action_callout_beacon`. (b) is «naprowadzanie» right for the + **feature** (`callouts_no_beacon_active`, + `settings_help_section_beacons_and_pois`)? (c) is «dźwięk naprowadzający» + fine for the **sound**, or too long? (Originally only (c), as an AI-only term + asked for confirmation. Reframed 2026-09-29.) 4. **Informal «ty» or formal «Pan/Pani»?** (PL-R1) 5. **«Znaczniki» or «pinezki»?** (PL-I1) 6. **«ślepa uliczka», «ślepa ulica» or «droga bez przejazdu»?** (PL-G1) @@ -152,7 +242,10 @@ Q1, Q2, Q3, Q5 and Q8 here), so map a reply by the pack it cites. side) 8. **Directions and distances**: `directions_*` is 108 strings spoken many times a day. Can they be shorter without losing clarity? -9. Anything else. +9. **`osm_beacon`** (a physical navigation beacon on the map) is + «Znacznik nawigacyjny», but «znacznik» is our Marker. «Znak nawigacyjny», + «Stawa», or something else? (PL-B1) +10. Anything else. --- @@ -208,3 +301,14 @@ Rules rather than kept as `fixed` — this entry is the record of it. **2026-09-28 — JJ's English rewording and UI-name markup (33 help/FAQ strings).** The existing translations were edited to follow the new English, not retranslated: 24 changed. Each whole string was checked against its English (C16). Where an edited sentence named a button differently from its real label, the text now uses the label. The UI names in `help_text_assistant_commands`, `help_text_assistant_commands_ios` and `help_text_remote_control_how` are now wrapped in `*…*` like the English (commit 2842d5a00). `help_config_voices_content_ios` keeps the localized iOS menu names this translation already used and drops JJ's "(In iOS versions prior to 26…)" note (`unconfirmed`: check the iOS 26 menu name on a device in this language). Uploaded and validated. **2026-09-28 — Weblate checks pass.** Byte `*_a11y` plurals: the «few» form had no number; `%1$s` restored. «Wybór głosu TTS» / «Syntezator mowy (TTS)» kept (the capitals check is ignored). Uploaded live. + +**2026-09-29 — first native-speaker input: *beacon* (PL-B1).** A written +argument, received in the session, that *beacon* needs different Polish words +for different meanings and that single-term glossaries and AI "consistency" +passes will keep undoing this. Accepted as a principle and recorded +cross-language as `_common.md` C19, with a carve-out added to C12. Swept all 80 +beacon units. Only the two one-off typos are ready to apply; the place and +feature strings wait on Q3 and Q9 (numbered Q10–Q12 in the first draft, merged into the published sheet the same day). The repo's `values-pl/strings.xml` was +behind Weblate for the reporter's two edits at the time of the sweep. + +The two one-off typos (`help_text_destination_beacons_when`, `help_text_routes_content_what`) were uploaded and verified live the same day.