From 696c71cd88aad349d9daba84c921e08e4a25fbf7 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 29 Sep 2026 08:46:29 +0100 Subject: [PATCH 1/3] Translation guidance: beacon has three meanings (pl feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Polish user reported that translating every "beacon" with one term («dźwięk naprowadzający») breaks strings where beacon means the place or the guidance feature rather than the sound. Record this as a cross-language rule (C19), carve beacon out of C12's split-corpus test, replace pl.md's single Beacon glossary row with one row per meaning, and log the sweep. Put the open questions on the published Polish sheet: Q3 now asks what to call each of the three meanings (it only asked whether the sound term was too long), osm_beacon's clash with «znacznik» is new Q9, and "Anything else" moves to Q10. pl.md is renumbered to match. Co-Authored-By: Claude Opus 5.5 --- docs/translation-questions/questions-pl.md | 67 ++++++++--- translations/guidance/_common.md | 44 ++++++++ translations/guidance/pl.md | 122 +++++++++++++++++++-- 3 files changed, 209 insertions(+), 24 deletions(-) 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/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. From 944538937da2e8b7be8c4babdcbdbd34a9903366 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 29 Sep 2026 09:27:26 +0100 Subject: [PATCH 2/3] Search: accept coordinates, map links and plus codes Text in the search bar that is a coordinate now gives that spot as the result instead of a name search. Understood are a pair of decimal numbers (with a point or comma decimal separator), degrees/minutes/ seconds or decimal minutes with N/S/E/W, geo: and Google/Apple/OSM map links, and plus codes - short ones completed near the user. A bare pair of numbers doesn't say which is the latitude, so both ways round are listed where both are valid, nearest first. Each result is named by its coordinate so the two can be told apart, and described by the geocoded address when that is within 200m. The plus code decoder is vendored from aughtone-openlocationcode 0.0.1-alpha2 (Apache-2.0, attribution in LICENSE.md): its latest release needs the Kotlin 2.4 KLIB ABI, which our 2.3.10 can't consume on iOS. Co-Authored-By: Claude Opus 5.5 --- LICENSE.md | 19 + .../soundscape/geoengine/GeoEngine.kt | 33 +- .../utils/geocoders/CoordinateSearch.kt | 185 +++++++ .../utils/openlocationcode/CodeArea.kt | 46 ++ .../openlocationcode/OpenLocationCode.kt | 486 ++++++++++++++++++ .../utils/openlocationcode/PlusCode.kt | 102 ++++ .../soundscape/intents/IntentParser.kt | 2 +- .../utils/geocoders/CoordinateSearchTest.kt | 219 ++++++++ 8 files changed, 1090 insertions(+), 2 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearch.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/CodeArea.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/OpenLocationCode.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/openlocationcode/PlusCode.kt create mode 100644 shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/CoordinateSearchTest.kt 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/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/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))) + } +} From 98e32868a2f125540cdc81823579ea6b894410c3 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 29 Sep 2026 10:20:55 +0100 Subject: [PATCH 3/3] Open locations in another map app from Location Details and lists Location Details gets an "Open in Maps App" button. It lists the installed map apps (Android: every geo: handler; iOS: Apple Maps plus known URL schemes for Google Maps, Waze, Citymapper, Moovit, Organic Maps and OsmAnd), with an "Always use this app" checkbox. Once an app is remembered the button reads e.g. "Open in Waze" and opens it directly; a long press (TalkBack: "double tap and hold to choose a different app", iOS: a VoiceOver custom action) brings the chooser back. Places Nearby and Markers list items gain "Open in ..." and "Share" screen reader actions alongside Start Beacon, sharing the same chooser. LocationItem now combines its custom actions rather than letting the playback action replace the reorder ones. formatCoordinate5 no longer produces exponent form ("-1.0E-4") for coordinates near the equator or the Greenwich meridian. Co-Authored-By: Claude Opus 5.5 --- .../soundscape/OpenInMapAppTest.kt | 293 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 5 + .../soundscape/screens/home/HomeScreen.kt | 4 + .../soundscape/utils/AndroidMapApps.kt | 52 ++++ iosApp/project.yml | 8 + .../composeResources/values/strings.xml | 14 + .../org/scottishtecharmy/soundscape/App.kt | 7 + .../soundscape/components/LocationItem.kt | 70 +++-- .../components/LocationListActions.kt | 40 +++ .../soundscape/navigation/SharedNavGraph.kt | 29 ++ .../preferences/PreferenceComposable.kt | 25 ++ .../preferences/PreferencesProvider.kt | 7 + .../home/locationDetails/OpenInMapApp.kt | 254 +++++++++++++++ .../SharedLocationDetailsScreen.kt | 14 + .../home/placesnearby/PlacesNearbyList.kt | 5 + .../home/placesnearby/PlacesNearbyScreen.kt | 3 + .../components/IconWithTextButton.kt | 22 +- .../screens/MarkersAndRoutesList.kt | 9 +- .../screens/markersscreen/MarkersScreen.kt | 7 +- .../soundscape/utils/ShareLocationText.kt | 21 +- .../soundscape/utils/ShareLocationTextTest.kt | 12 + .../scottishtecharmy/soundscape/IosMapApps.kt | 65 ++++ .../soundscape/MainViewController.kt | 2 + 23 files changed, 929 insertions(+), 39 deletions(-) create mode 100644 app/src/androidTest/java/org/scottishtecharmy/soundscape/OpenInMapAppTest.kt create mode 100644 app/src/main/java/org/scottishtecharmy/soundscape/utils/AndroidMapApps.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/LocationListActions.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/locationDetails/OpenInMapApp.kt create mode 100644 shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosMapApps.kt 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/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/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/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")