diff --git a/.github/scripts/android-smoke.sh b/.github/scripts/android-smoke.sh new file mode 100755 index 0000000..f626be8 --- /dev/null +++ b/.github/scripts/android-smoke.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Drives TST Go on an emulator through the two reported bugs: +# 1. after a while (app killed, reopened) the pin jumped back to New York +# 2. no way to stop the fake GPS +# Writes report.md and screenshots to $OUT. Exits 1 if any check fails. +set -u +PKG=com.thatsimpletech.tstgo +APK="${APK:?set APK}" +OUT="${OUT:?set OUT}" +HERE="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$OUT" +REPORT="$OUT/report.md" +: > "$REPORT" +fails=0 + +log() { echo "$*" | tee -a "$REPORT"; } +ok() { log "- PASS: $1"; } +bad() { log "- FAIL: $1"; fails=$((fails + 1)); } +shot() { adb exec-out screencap -p > "$OUT/$1.png" 2>/dev/null || true; } +dump() { + local i + rm -f "$OUT/ui.xml" + for i in 1 2 3; do + adb shell rm -f /sdcard/ui.xml >/dev/null 2>&1 + adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 + adb pull /sdcard/ui.xml "$OUT/ui.xml" >/dev/null 2>&1 && return 0 + sleep 2 + done + return 1 +} +ui() { dump; python3 "$HERE/ui.py" "$OUT/ui.xml" "$1" "$2"; } +tap() { + local xy + xy=$(ui center "$1") || { bad "could not find '$1' on screen"; return 1; } + adb shell input tap $xy +} +hud() { ui text id:hudCoords; } +button() { ui text id:broadcastBtn; } +svc_running() { adb shell dumpsys activity services "$PKG" | grep -q "MockLocationService"; } +gps_shows() { adb shell dumpsys location | grep -q "$1"; } +open_app() { adb shell am start -W -n "$PKG/.MainActivity" >/dev/null 2>&1; sleep 4; } + +adb root >/dev/null 2>&1 || true +sleep 3 +log "## Android $(adb shell getprop ro.build.version.release | tr -d '\r') (API $(adb shell getprop ro.build.version.sdk | tr -d '\r'))" +log "" + +adb install -r -g "$APK" >/dev/null || { bad "APK did not install"; exit 1; } +adb shell appops set "$PKG" android:mock_location allow +adb shell cmd appops set "$PKG" POST_NOTIFICATION allow >/dev/null 2>&1 || true + +log "### 1. Fresh install" +open_app +shot 01-fresh-install +title=$(ui text id:appTitle) +[[ "$title" == *1.0.10* ]] && ok "header shows the version: $title" || bad "header shows '$title', expected 1.0.10" +[ "$(button)" = "Start GPS" ] && ok "does not start fake GPS before you pick a spot" || bad "button says '$(button)' on a fresh install" +svc_running && bad "GPS service already running before a spot was picked" || ok "phone keeps its real GPS until you pick a spot" + +log "### 2. Pick Chicago" +tap id:search && adb shell input text "41.8781,-87.6298" +sleep 1 +tap id:searchGo +sleep 5 +shot 02-picked-chicago +coords=$(hud) +[[ "$coords" == 41.878100* ]] && ok "pin moved to Chicago ($coords)" || bad "pin shows '$coords' after search" +[ "$(button)" = "Stop GPS" ] && ok "main screen shows a Stop GPS button" || bad "button says '$(button)' while live" +svc_running && ok "GPS service running" || bad "GPS service not running after the jump" +gps_shows "41.8781" && ok "phone GPS reports Chicago" || bad "phone GPS does not report Chicago" +adb shell dumpsys notification --noredact | grep -q "Stop GPS" && ok "notification has a Stop GPS button" || bad "notification has no Stop GPS button" +adb shell dumpsys shortcut | grep -q "stop_gps" && ok "long-press icon shortcut Stop GPS is registered" || bad "Stop GPS shortcut missing" +adb shell dumpsys appwidget | grep -q "StopWidget" && ok "home screen widget is available" || bad "stop widget missing" + +log "### 3. Android kills the app in the background" +adb shell input keyevent KEYCODE_HOME +sleep 2 +pid=$(adb shell pidof "$PKG" | tr -d '\r') +if [ -n "$pid" ]; then + adb shell kill -9 "$pid" >/dev/null 2>&1 || adb shell run-as "$PKG" kill -9 "$pid" >/dev/null 2>&1 || true +fi +sleep 15 +if svc_running; then + ok "Android restarted the GPS service on its own" + gps_shows "41.8781" && ok "restarted service still broadcasts Chicago" || bad "restarted service lost Chicago" +else + log "- NOTE: Android did not restart the service within 15 s (fine; reopening the app resumes it)" +fi +open_app +shot 03-reopen-after-kill +coords=$(hud) +[[ "$coords" == 41.878100* ]] && ok "reopened at Chicago, not New York ($coords)" || bad "reopened at '$coords'" + +log "### 4. Swipe away / reboot (force stop), then reopen" +adb shell am force-stop "$PKG" +sleep 2 +open_app +shot 04-reopen-after-force-stop +coords=$(hud) +[[ "$coords" == 41.878100* ]] && ok "reopened at Chicago, not New York ($coords)" || bad "reopened at '$coords'" +[ "$(button)" = "Stop GPS" ] && ok "fake GPS resumed at the saved spot" || bad "button says '$(button)' after reopening" +sleep 2 +gps_shows "41.8781" && ok "phone GPS reports Chicago again" || bad "phone GPS does not report Chicago after reopening" + +log "### 5. Stop GPS from the main screen" +tap id:broadcastBtn +sleep 4 +shot 05-stopped-from-app +svc_running && bad "GPS service still running after tapping Stop GPS" || ok "Stop GPS button on the main screen turns fake GPS off" +[ "$(button)" = "Start GPS" ] && ok "button switches back to Start GPS" || bad "button says '$(button)' after stopping" + +log "### 6. Stop GPS from the notification" +tap id:broadcastBtn +sleep 4 +svc_running || bad "Start GPS did not restart the service" +adb shell cmd statusbar expand-notifications +sleep 3 +shot 06-notification +if tap contains:"Stop GPS"; then + sleep 4 + svc_running && bad "GPS service still running after notification Stop GPS" || ok "notification Stop GPS turns fake GPS off" +fi +adb shell cmd statusbar collapse >/dev/null 2>&1 || true +sleep 2 +shot 07-after-notification-stop +[ "$(button)" = "Start GPS" ] && ok "app screen updates after stopping from the notification" || bad "app button says '$(button)' after notification stop" + +log "### 7. Stop GPS from the long-press shortcut" +tap id:broadcastBtn +sleep 4 +adb shell am start -n "$PKG/.StopActivity" >/dev/null 2>&1 +sleep 4 +svc_running && bad "GPS service still running after the shortcut" || ok "Stop GPS shortcut turns fake GPS off" + +log "### 8. Still remembers Chicago after all that" +adb shell am force-stop "$PKG" +open_app +shot 08-final-reopen +coords=$(hud) +[[ "$coords" == 41.878100* ]] && ok "still Chicago ($coords)" || bad "final reopen shows '$coords'" + +log "### 9. Search a street address (uses the live map and US Census services)" +tap id:search +adb shell input keyevent KEYCODE_MOVE_END +for _ in $(seq 1 40); do adb shell input keyevent KEYCODE_DEL; done +adb shell input text "4600%sSilver%sHill%sRd%sApt%s2,%sWashington,%sDC%s20233" +sleep 1 +tap id:searchGo +sleep 12 +shot 09-address-search +coords=$(hud) +label=$(ui text id:hudLabel) +[[ "$coords" == 38.84* ]] && ok "found the address: $label ($coords)" || bad "address search landed on '$label' ($coords)" + +log "### Never broadcast New York" +adb shell dumpsys location > "$OUT/dumpsys-location.txt" 2>&1 +grep -q "40.758000" "$OUT/dumpsys-location.txt" && bad "Times Square (40.758) shows up in the phone's location log" || ok "Times Square never reached the phone's GPS" + +adb logcat -d > "$OUT/logcat.txt" 2>&1 || true +grep -E "FATAL EXCEPTION|AndroidRuntime: (FATAL|Process: $PKG)" "$OUT/logcat.txt" > "$OUT/crashes.txt" || true +[ -s "$OUT/crashes.txt" ] && bad "app crashed (see crashes.txt)" || ok "no crashes in the log" + +log "" +log "**$fails failed check(s)**" +[ "$fails" -eq 0 ] diff --git a/.github/scripts/ui.py b/.github/scripts/ui.py new file mode 100755 index 0000000..2e06374 --- /dev/null +++ b/.github/scripts/ui.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Look up a view in a `uiautomator dump` file. + +usage: ui.py DUMP.xml (center|text) (id:NAME | text:EXACT | contains:PART) +Prints the view's tap point ("x y") or its text. Exits 1 if not found. +""" +import re +import sys +import xml.etree.ElementTree as ET + +path, mode, query = sys.argv[1:4] +kind, _, value = query.partition(":") +try: + root = ET.parse(path).getroot() +except Exception: + sys.exit(1) + +for node in root.iter("node"): + rid = node.get("resource-id", "") + text = node.get("text", "") + desc = node.get("content-desc", "") + if kind == "id": + hit = rid.endswith(":id/" + value) + elif kind == "text": + hit = text == value + else: + hit = value.lower() in text.lower() or value.lower() in desc.lower() + if not hit: + continue + if mode == "text": + print(text) + sys.exit(0) + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", "")) + if not m: + continue + x1, y1, x2, y2 = map(int, m.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + sys.exit(0) +sys.exit(1) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000..eae633a --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,92 @@ +name: Android release (Google Play) + +# Run by hand: Actions tab -> "Android release (Google Play)" -> Run workflow. +# Needs these repository secrets (Settings -> Secrets and variables -> Actions): +# ANDROID_UPLOAD_KEYSTORE_BASE64 the upload keystore (.jks), base64-encoded +# ANDROID_KEYSTORE_PASSWORD keystore password +# ANDROID_KEY_ALIAS key alias (tstgo in keystore.properties.example) +# ANDROID_KEY_PASSWORD key password +# PLAY_SERVICE_ACCOUNT_JSON Play Console service account key (only to upload) + +on: + workflow_dispatch: + inputs: + track: + description: "Play track" + type: choice + options: [internal, alpha, beta, production] + default: internal + upload: + description: "Upload to Google Play (off = just build the signed .aab)" + type: boolean + default: true + draft: + description: "Create the release as a draft (required if the app was never published)" + type: boolean + default: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + + - name: Write signing config + env: + KEYSTORE_B64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ -z "$KEYSTORE_B64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ] || [ -z "$KEY_PASSWORD" ]; then + echo "::error::Missing upload key secrets. Add ANDROID_UPLOAD_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD." + exit 1 + fi + echo "$KEYSTORE_B64" | base64 -d > android/upload-keystore.jks + { + echo "storeFile=upload-keystore.jks" + echo "storePassword=$STORE_PASSWORD" + echo "keyAlias=$KEY_ALIAS" + echo "keyPassword=$KEY_PASSWORD" + } > android/keystore.properties + + - name: Build signed App Bundle + working-directory: android + run: ./gradlew bundleRelease --no-daemon --stacktrace + + - uses: actions/upload-artifact@v4 + with: + name: tst-go-release-aab + path: android/app/build/outputs/bundle/release/app-release.aab + if-no-files-found: error + + - name: Check Play access + if: ${{ inputs.upload }} + env: + PLAY_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + if [ -z "$PLAY_JSON" ]; then + echo "::error::Missing PLAY_SERVICE_ACCOUNT_JSON. Add it, or rerun with upload off and upload the .aab in Play Console." + exit 1 + fi + + - name: Upload to Google Play + if: ${{ inputs.upload }} + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.thatsimpletech.tstgo + releaseFiles: android/app/build/outputs/bundle/release/app-release.aab + track: ${{ inputs.track }} + # Production always lands as a draft so a person sends it for review in Play Console. + status: ${{ (inputs.draft || inputs.track == 'production') && 'draft' || 'completed' }} + + - name: Remove signing files + if: always() + run: rm -f android/upload-keystore.jks android/keystore.properties diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..29da3a5 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,146 @@ +name: Android build + +on: + push: + branches-ignore: + - "ci/**" + paths: + - "android/**" + - ".github/workflows/android.yml" + - ".github/scripts/**" + workflow_dispatch: + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + # Sign every test APK with the same key so each one installs over the last. + # (Otherwise each run makes a new key and Android refuses the update.) + # Order of preference: the TSTGO_TEST_KEYSTORE_BASE64 secret if set, then the key + # saved in the Actions cache, else a new key that the cache then keeps. + - name: Restore the test signing key + uses: actions/cache@v4 + with: + path: ~/tstgo-test.keystore + key: tstgo-test-keystore-v1 + - name: Prepare the test signing key + env: + TEST_KEYSTORE_B64: ${{ secrets.TSTGO_TEST_KEYSTORE_BASE64 }} + run: | + ks="$HOME/tstgo-test.keystore" + if [ -n "$TEST_KEYSTORE_B64" ]; then + echo "$TEST_KEYSTORE_B64" | base64 -d > "$ks" + echo "Using the key from the TSTGO_TEST_KEYSTORE_BASE64 secret" + elif [ -f "$ks" ]; then + echo "Using the saved test key" + else + keytool -genkeypair -keystore "$ks" -storepass android -keypass android \ + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=TST Go test build, O=That Simple Tech, C=US" + echo "Created a new test key" + fi + keytool -list -v -keystore "$ks" -storepass android | grep -m1 "SHA256:" + echo "TSTGO_DEBUG_KEYSTORE=$ks" >> "$GITHUB_ENV" + - name: Build debug APK and release bundle + # The release bundle here is signed with the test key; it only checks that a + # release build (including Play's lint checks) succeeds. See android-release.yml. + run: ./gradlew assembleDebug bundleRelease --no-daemon --stacktrace + - name: Unit tests + run: ./gradlew testDebugUnitTest --no-daemon --stacktrace + - name: Live address search check (report only) + continue-on-error: true + env: + GEOCODE_LIVE: "1" + GEOCODE_REPORT: ${{ github.workspace }}/geocode/report.md + run: ./gradlew testDebugUnitTest --tests '*AddressTest.liveAddressSearch' --rerun --no-daemon + - uses: actions/upload-artifact@v4 + if: always() + with: + name: geocode + path: geocode + if-no-files-found: ignore + - uses: actions/upload-artifact@v4 + with: + name: tst-go-debug-apk + path: android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + + # Installs the APK on clean emulators and walks through the reported bugs: + # pick a spot, let Android kill the app, reopen, and stop GPS every way. + smoke: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + api: [30, 35] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: tst-go-debug-apk + path: apk + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run on Android emulator (API ${{ matrix.api }}) + uses: reactivecircus/android-emulator-runner@v2 + env: + APK: apk/app-debug.apk + OUT: smoke-api${{ matrix.api }} + with: + api-level: ${{ matrix.api }} + target: google_apis + arch: x86_64 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: bash .github/scripts/android-smoke.sh + - uses: actions/upload-artifact@v4 + if: always() + with: + name: smoke-api${{ matrix.api }} + path: smoke-api${{ matrix.api }} + + # Puts the APK and test results on the ci/android-output branch so they can be + # fetched with git (no GitHub sign-in or zip download needed). + publish: + needs: [build, smoke] + if: always() && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: out + - name: Push to ci/android-output + env: + TOKEN: ${{ github.token }} + run: | + cd out + mv tst-go-debug-apk/app-debug.apk ./tst-go-debug.apk + rmdir tst-go-debug-apk + printf 'commit=%s\nbranch=%s\nsmoke=%s\n' "$GITHUB_SHA" "$GITHUB_REF_NAME" "${{ needs.smoke.result }}" > BUILD_INFO + git init -q -b output + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -q -m "Android build and emulator results for $GITHUB_SHA" + git push -f "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:refs/heads/ci/android-output diff --git a/.gitignore b/.gitignore index 4007c97..c3f10e9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ android/local.properties *.jks *.keystore android/keystore.properties + +# python +__pycache__/ +*.pyc diff --git a/README.md b/README.md index e26568b..bda2541 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ A **browser tab without the extension** cannot inject GPS into other apps. The w Requires Android 8 or newer. Not listed on the Play Store; sideload only. +**Stop GPS** (gets your real GPS back): tap the red **Stop GPS** button on the main screen, next to "Broadcast to other apps". The header shows the installed version, for example "TST Go 1.0.10". + +**Stop it without opening the app**: + +- Pull down the notification shade and tap **Stop GPS** on the TST Go notification. +- Or long-press the TST Go icon and tap **Stop GPS**. Drag that item onto the home screen to keep a one-tap stop button there. +- Or add the **TST Go stop button** widget: long-press the home screen → Widgets → TST Go. + +TST Go remembers your last pin. Reopening the app, or Android restarting it in the background, keeps you where you were instead of jumping back to the default. On a fresh install it leaves your real GPS alone until you pick a spot. + ### Phone (browser app) 1. Open TST Go in **Safari** (iPhone / iPad) or **Chrome** (Android). @@ -85,6 +95,17 @@ cd android Copy `android/keystore.properties.example` to `keystore.properties` and point it at your upload keystore. Keep that keystore private — losing it means you cannot update the Play listing. +**Release from GitHub instead** (no Android Studio needed): + +1. Repo → Settings → Secrets and variables → Actions → add: + - `ANDROID_UPLOAD_KEYSTORE_BASE64` — the upload keystore, base64-encoded (`base64 -w0 upload-keystore.jks` on Linux, `base64 -i upload-keystore.jks` on Mac) + - `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` + - `PLAY_SERVICE_ACCOUNT_JSON` — optional; a Google Cloud service account key that has release access in Play Console → Users and permissions. Without it, the workflow only builds the signed `.aab` for you to upload by hand. +2. Actions → **Android release (Google Play)** → Run workflow. Pick a track (start with **internal**). +3. Bump `versionCode` in `android/app/build.gradle.kts` before every new upload; Play rejects a code it has already seen. + +Production uploads are created as a draft so you send them for review in Play Console. Google Play requires new updates to target Android 16 (API 36); this app does. + Listing copy lives in `play/STORE_LISTING.md`. Privacy policy: `public/privacy.html`. @@ -104,6 +125,8 @@ You can also use GitHub → **Code** → **Download ZIP** if you do not use git. - **Export GPX** of the route (web) - **Streets or satellite** map (web) - **Broadcast to other apps** (Android APK, mock location) +- **Stop from the home screen**: notification button, icon shortcut, or widget (Android APK) +- Your last pin is **remembered** after a reload or restart (web and Android) - **Spoof GPS in Chrome/Edge/Firefox** (Mac / Windows / Linux extension) Favorites and history stay on this device. There is no account. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index df2a688..da838e8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,15 +13,26 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.thatsimpletech.tstgo" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "com.thatsimpletech.tstgo" minSdk = 26 - targetSdk = 34 - versionCode = 8 - versionName = "1.0.7" + targetSdk = 36 + versionCode = 11 + versionName = "1.0.10" } signingConfigs { + // CI signs test APKs with one saved key (TSTGO_DEBUG_KEYSTORE) so each new + // test build installs over the last one. Local builds keep the usual debug key. + val ciDebugKeystore = System.getenv("TSTGO_DEBUG_KEYSTORE")?.let { file(it) } + if (ciDebugKeystore != null && ciDebugKeystore.exists()) { + getByName("debug") { + storeFile = ciDebugKeystore + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } if (keystorePropertiesFile.exists()) { create("release") { keyAlias = keystoreProperties.getProperty("keyAlias") @@ -61,4 +72,8 @@ dependencies { implementation("org.osmdroid:osmdroid-android:6.1.20") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("com.google.android.gms:play-services-location:21.3.0") + + testImplementation("junit:junit:4.13.2") + // Real org.json for JVM tests (android.jar only ships stubs). + testImplementation("org.json:json:20240303") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6000e3c..b3a9d90 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,16 +30,42 @@ android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize|keyboardHidden" + android:launchMode="singleTask" android:windowSoftInputMode="adjustNothing"> + + + + + + + + + + diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/Geo.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/Geo.kt index 0df87ca..b38c3c8 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/Geo.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/Geo.kt @@ -121,21 +121,99 @@ private fun httpGet(url: String): String { return body } -fun searchPlaces(q: String): List { +/** + * Finds places for the search box. + * + * [thorough] is for the Search button: it also asks the US Census Bureau (which knows almost + * every US street address, unlike OpenStreetMap) and Nominatim, then falls back to the city. + * While typing, only Photon is used: it is fast and allows search-as-you-type. + * [near] nudges results toward the current pin when the query has no city. + */ +fun searchPlaces( + q: String, + near: LatLng? = null, + thorough: Boolean = true, + useCensus: Boolean = true, +): List { + val query = cleanAddress(q) + if (query.isBlank()) return emptyList() + val exact = mutableListOf() + if (thorough && useCensus && looksLikeStreetAddress(query)) { + try { + exact += searchCensus(query) + } catch (_: Exception) { + } + } val raw = mutableListOf() try { - raw += searchPhoton(q) + raw += searchPhoton(query, near) } catch (_: Exception) { } - try { - raw += searchNominatim(q) - } catch (_: Exception) { + if (thorough) { + try { + raw += searchNominatim(query) + } catch (_: Exception) { + } } - val ranked = rankHits(q, raw) - if (ranked.isNotEmpty()) return ranked - return fallbackRegion(q) + val ranked = (exact + rankHits(query, raw)) + .distinctBy { "%.4f,%.4f".format(it.lat, it.lng) } + .take(5) + if (ranked.isNotEmpty() || !thorough) return ranked + return fallbackRegion(query) } +/** "123 Main St Apt 4B, Town" -> "123 Main St, Town". Unit numbers make every geocoder miss. */ +fun cleanAddress(q: String): String = + q.replace(UNIT_RE, "") + .replace(Regex("""\s+"""), " ") + .replace(Regex("""\s+,"""), ",") + .trim() + .trim(',') + .trim() + +private val UNIT_RE = Regex( + """(?i)(?:,\s*|\s+)(?:apt|apartment|unit|suite|ste|bldg|building|fl|floor|rm|room|lot)(?:\.\s*|\s+|\s*#\s*)(?:[a-z]?\d[a-z0-9-]*|[a-z])(?=,|\s|$)|\s*#\s*[a-z0-9-]+(?=,|\s|$)""", +) + +/** Starts with a house number followed by a street name, e.g. "123 Main St". */ +fun looksLikeStreetAddress(q: String): Boolean = + Regex("""^\d+[a-zA-Z]?(?:-\d+)?\s+[A-Za-z0-9]""").containsMatchIn(q.trim()) + +/** US Census Bureau geocoder: free, no key, covers nearly every US street address. */ +private fun searchCensus(q: String): List { + val url = + "https://geocoding.geo.census.gov/geocoder/locations/onelineaddress?benchmark=Public_AR_Current&format=json&address=" + + URLEncoder.encode(q, "UTF-8") + val matches = JSONObject(httpGet(url)) + .optJSONObject("result") + ?.optJSONArray("addressMatches") + ?: return emptyList() + val out = ArrayList(matches.length()) + for (i in 0 until matches.length()) { + val m = matches.getJSONObject(i) + val c = m.optJSONObject("coordinates") ?: continue + val lng = c.optDouble("x") + val lat = c.optDouble("y") + if (!lat.isFinite() || !lng.isFinite()) continue + // "4600 SILVER HILL RD, WASHINGTON, DC, 20233" + val parts = m.optString("matchedAddress").split(",").map { it.trim() }.filter { it.isNotEmpty() } + val street = titleCase(parts.firstOrNull() ?: q) + val rest = parts.drop(1).mapIndexed { idx, part -> + if (idx == 0) titleCase(part) else part + } + out.add(PlaceHit(street, rest.joinToString(", "), lat, lng, "house")) + } + return out +} + +private fun titleCase(s: String): String = + s.lowercase().split(" ").joinToString(" ") { word -> + when { + word.length <= 2 && word in setOf("ne", "nw", "se", "sw") -> word.uppercase() + else -> word.replaceFirstChar { it.uppercase() } + } + } + private val STOP_WORDS = setOf( "the", "of", "and", "al", "usa", "us", "united", "states", "street", "st", "ave", "rd", "dr", "ln", "ct", "trl", "trail", "east", "west", "north", "south", @@ -192,8 +270,9 @@ private fun fallbackRegion(q: String): List { return emptyList() } -private fun searchPhoton(q: String): List { - val url = "https://photon.komoot.io/api/?limit=7&q=" + URLEncoder.encode(q, "UTF-8") +private fun searchPhoton(q: String, near: LatLng? = null): List { + var url = "https://photon.komoot.io/api/?limit=7&q=" + URLEncoder.encode(q, "UTF-8") + if (near != null) url += "&lat=${near.lat}&lon=${near.lng}" val feats = JSONObject(httpGet(url)).optJSONArray("features") ?: return emptyList() val out = ArrayList(feats.length()) for (i in 0 until feats.length()) { diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/LastPin.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/LastPin.kt new file mode 100644 index 0000000..db06845 --- /dev/null +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/LastPin.kt @@ -0,0 +1,58 @@ +package com.thatsimpletech.tstgo + +import android.content.Context +import android.os.SystemClock + +/** + * Remembers the last pin on disk so a restart (app reopened, process killed, + * service restarted by Android, phone rebooted) comes back to the same spot + * instead of the built-in default. + */ +object LastPin { + data class Pin(val lat: Double, val lng: Double, val label: String) + + private const val PREFS = "tstgo" + private const val K_LAT = "last_lat" + private const val K_LNG = "last_lng" + private const val K_LABEL = "last_label" + + private var savedLat = Double.NaN + private var savedLng = Double.NaN + private var savedLabel: String? = null + private var savedAt = 0L + + fun load(ctx: Context): Pin? { + val prefs = ctx.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (!prefs.contains(K_LAT) || !prefs.contains(K_LNG)) return null + val lat = Double.fromBits(prefs.getLong(K_LAT, 0L)) + val lng = Double.fromBits(prefs.getLong(K_LNG, 0L)) + if (!lat.isFinite() || !lng.isFinite()) return null + if (lat < -90.0 || lat > 90.0 || lng < -180.0 || lng > 180.0) return null + val label = prefs.getString(K_LABEL, null)?.takeIf { it.isNotBlank() } + ?: formatPair(LatLng(lat, lng)) + return Pin(lat, lng, label) + } + + /** + * Saves the pin. While moving, writes are throttled to one every couple of + * seconds; pass [force] for jumps and when the app goes to the background. + */ + fun save(ctx: Context, lat: Double, lng: Double, label: String, force: Boolean = false) { + if (!lat.isFinite() || !lng.isFinite()) return + val now = SystemClock.elapsedRealtime() + if (!force && label == savedLabel && !savedLat.isNaN()) { + if (now - savedAt < 2000) return + if (haversine(LatLng(savedLat, savedLng), LatLng(lat, lng)) < 3.0) return + } + ctx.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putLong(K_LAT, lat.toRawBits()) + .putLong(K_LNG, lng.toRawBits()) + .putString(K_LABEL, label) + .apply() + savedLat = lat + savedLng = lng + savedLabel = label + savedAt = now + } +} diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt index c02f19f..a74c0d9 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt @@ -23,6 +23,10 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams import com.thatsimpletech.tstgo.databinding.ActivityMainBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -46,6 +50,16 @@ class MainActivity : AppCompatActivity() { private var line: Polyline? = null private var lastTick = SystemClockElapsed() private var searchJob: Job? = null + private var broadcastOn = false + + /** True once there is a spot worth remembering: a saved one, or one you picked or started. */ + private var hasChosen = false + private var paintedOn: Boolean? = null + private var paintedSaved: Boolean? = null + private var paintedRunning: Boolean? = null + private var drawnAt: LatLng? = null + private var drawnHeading = Double.NaN + private val gpsStopped: () -> Unit = { ui.post { onGpsStopped() } } private val tick = object : Runnable { override fun run() { @@ -53,11 +67,17 @@ class MainActivity : AppCompatActivity() { engine.tick(now - lastTick) lastTick = now val p = engine.pos() - MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh) + MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh, engine.label) + if (engine.running) hasChosen = true + if (hasChosen) LastPin.save(this@MainActivity, p.lat, p.lng, engine.label) updateHud() - marker?.position = GeoPoint(p.lat, p.lng) - marker?.rotation = (-engine.heading).toFloat() - bind.map.invalidate() + if (p != drawnAt || engine.heading != drawnHeading) { + drawnAt = p + drawnHeading = engine.heading + marker?.position = GeoPoint(p.lat, p.lng) + marker?.rotation = (-engine.heading).toFloat() + bind.map.invalidate() + } if (engine.running && !engine.paused) { val stick = kotlin.math.hypot(engine.stickX.toDouble(), engine.stickY.toDouble()) if (stick > 0.08 || engine.speedKmh > 0.4) { @@ -74,7 +94,12 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) bind = ActivityMainBinding.inflate(layoutInflater) setContentView(bind.root) + fitSystemBars() favs = FavoritesStore(this) + val hadSavedPin = restoreLastPin() + hasChosen = hadSavedPin + MockBus.onStopped = gpsStopped + showVersion() askPerms() bind.map.setTileSource(EsriStreetTiles()) @@ -104,6 +129,7 @@ class MainActivity : AppCompatActivity() { bind.search.setOnEditorActionListener { v, action, _ -> if (action == EditorInfo.IME_ACTION_SEARCH || action == EditorInfo.IME_ACTION_DONE) { + ui.removeCallbacks(searchDebounce) doSearch(v.text.toString(), quiet = false) hideKeyboard() true @@ -114,10 +140,11 @@ class MainActivity : AppCompatActivity() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { ui.removeCallbacks(searchDebounce) - ui.postDelayed(searchDebounce, 280) + ui.postDelayed(searchDebounce, 450) } }) bind.searchGo.setOnClickListener { + ui.removeCallbacks(searchDebounce) doSearch(bind.search.text.toString(), quiet = false) hideKeyboard() } @@ -144,21 +171,97 @@ class MainActivity : AppCompatActivity() { } bind.broadcastRow.setOnClickListener { MockBus.openDeveloperSettings(this) } - bind.broadcastSwitch.setOnCheckedChangeListener { _, on -> - if (on) startBroadcast() else MockLocationService.stop(this) - paintBroadcast() + bind.broadcastBtn.setOnClickListener { + if (broadcastOn) stopFromApp() else setBroadcast(true) } lastTick = android.os.SystemClock.elapsedRealtime() ui.post(tick) updateHud() val start = engine.pos() - MockBus.setFix(start.lat, start.lng, engine.heading, engine.speedKmh) - bind.broadcastSwitch.isChecked = true - paintBroadcast() + MockBus.setFix(start.lat, start.lng, engine.heading, engine.speedKmh, engine.label) + // Pick up where you left off. With nothing saved yet (fresh install), leave the + // phone's real GPS alone until you choose a spot, instead of broadcasting the default. + if (hadSavedPin || MockBus.running) setBroadcast(true) else paintBroadcast() paintSave() } + /** + * Android 15+ always draws apps edge to edge. Do the same on older phones and pad + * the controls so they never sit under the status bar, notch, or navigation bar. + */ + private fun fitSystemBars() { + WindowCompat.setDecorFitsSystemWindows(window, false) + val top = bind.topBar + val bottom = bind.bottomBar + val topPad = intArrayOf(top.paddingLeft, top.paddingTop, top.paddingRight, top.paddingBottom) + val bottomPad = intArrayOf(bottom.paddingLeft, bottom.paddingTop, bottom.paddingRight, bottom.paddingBottom) + ViewCompat.setOnApplyWindowInsetsListener(bind.root) { _, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + top.setPadding(topPad[0] + bars.left, topPad[1] + bars.top, topPad[2] + bars.right, topPad[3]) + bottom.setPadding( + bottomPad[0] + bars.left, + bottomPad[1], + bottomPad[2] + bars.right, + bottomPad[3] + bars.bottom, + ) + bind.statusScrim.updateLayoutParams { height = bars.top } + bind.navScrim.updateLayoutParams { height = bars.bottom } + insets + } + } + + /** Comes back to the last pin instead of the built-in default (Times Square). */ + private fun restoreLastPin(): Boolean { + // If the GPS service is still running, show exactly what it is broadcasting. + val live = if (MockBus.running && MockBus.hasFix) LastPin.Pin(MockBus.lat, MockBus.lng, MockBus.label) else null + val saved = live ?: LastPin.load(this) ?: return false + engine.pick = LatLng(saved.lat, saved.lng) + engine.label = saved.label + return true + } + + /** Shows the installed version in the header so it's obvious which build is running. */ + private fun showVersion() { + val version = try { + packageManager.getPackageInfo(packageName, 0).versionName + } catch (_: Exception) { + null + } + bind.appTitle.text = if (version.isNullOrBlank()) getString(R.string.app_name) else "${getString(R.string.app_name)} $version" + } + + private fun setBroadcast(on: Boolean) { + broadcastOn = on + if (on) hasChosen = true + if (on) startBroadcast() else MockLocationService.stop(this) + paintBroadcast() + } + + /** The Stop GPS button on the main screen. */ + private fun stopFromApp() { + broadcastOn = false + engine.stop() + stopFakeGps(this) + paintBroadcast() + paintGo() + updateHud() + } + + /** GPS was stopped from the notification, widget, or shortcut. Match the screen to it. */ + private fun onGpsStopped() { + if (isFinishing || isDestroyed || MockBus.running) return + if (broadcastOn) { + broadcastOn = false + engine.stop() + } + paintBroadcast() + paintGo() + updateHud() + } + private fun toggleSave() { val p = engine.pos() val nowSaved = favs.toggle(engine.label, p.lat, p.lng) @@ -227,10 +330,14 @@ class MainActivity : AppCompatActivity() { private fun paintSave() { val p = engine.pos() - val saved = favs.nearby(p.lat, p.lng) != null - bind.saveBtn.text = getString(if (saved) R.string.saved_pin else R.string.save_pin) - bind.saveBtn.setBackgroundResource(if (saved) R.drawable.bg_mode_on else R.drawable.bg_mode) - bind.placesBtn.text = if (favs.all().isEmpty()) getString(R.string.places) else "Places (${favs.all().size})" + val all = favs.all() + val saved = all.any { haversine(LatLng(it.lat, it.lng), p) < 40.0 } + if (paintedSaved != saved) { + paintedSaved = saved + bind.saveBtn.text = getString(if (saved) R.string.saved_pin else R.string.save_pin) + bind.saveBtn.setBackgroundResource(if (saved) R.drawable.bg_mode_on else R.drawable.bg_mode) + } + bind.placesBtn.show(if (all.isEmpty()) getString(R.string.places) else "Places (${all.size})") } private fun startBroadcast() { @@ -318,10 +425,12 @@ class MainActivity : AppCompatActivity() { return } searchJob?.cancel() + val near = engine.pos() searchJob = scope.launch { val hits = withContext(Dispatchers.IO) { try { - searchPlaces(query) + // While typing: quick suggestions. Search button: every source, incl. US Census. + searchPlaces(query, near = near, thorough = !quiet) } catch (_: Exception) { emptyList() } @@ -330,7 +439,7 @@ class MainActivity : AppCompatActivity() { if (hits.isEmpty()) { hideResults() if (!quiet) { - Toast.makeText(this@MainActivity, "No places match that search", Toast.LENGTH_SHORT).show() + Toast.makeText(this@MainActivity, R.string.search_none, Toast.LENGTH_LONG).show() } return@launch } @@ -352,6 +461,7 @@ class MainActivity : AppCompatActivity() { goTo(LatLng(hit.lat, hit.lng), hit.name) hideResults() bind.search.setText(hit.name) + ui.removeCallbacks(searchDebounce) hideKeyboard() } } @@ -372,10 +482,15 @@ class MainActivity : AppCompatActivity() { engine.waypoints.add(engine.pos()) engine.waypoints.add(p) engine.teleport(p, name) - MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh) + hasChosen = true + MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh, name) + LastPin.save(this, p.lat, p.lng, name, force = true) hideResults() + broadcastOn = true if (!MockBus.running) startBroadcast() - bind.broadcastSwitch.isChecked = true + paintBroadcast() + MockLocationService.refresh(this) + StopWidget.refresh(this) bind.map.controller.animateTo(GeoPoint(p.lat, p.lng), 16.0, 400L) marker?.position = GeoPoint(p.lat, p.lng) marker?.title = name @@ -390,29 +505,28 @@ class MainActivity : AppCompatActivity() { } private fun paintBroadcast() { - val on = bind.broadcastSwitch.isChecked + val on = broadcastOn + if (paintedOn != on) { + paintedOn = on + bind.broadcastBtn.text = getString(if (on) R.string.stop_gps else R.string.start_gps) + bind.broadcastBtn.setBackgroundResource(if (on) R.drawable.bg_stop else R.drawable.bg_go) + bind.broadcastBtn.setTextColor( + ContextCompat.getColor(this, if (on) R.color.stop_fg else R.color.live_fg), + ) + } val selected = MockBus.isSelectedMockApp(this) val muted = ContextCompat.getColor(this, R.color.subtle) val live = ContextCompat.getColor(this, R.color.live) val stop = ContextCompat.getColor(this, R.color.stop) - when { - !on -> { - bind.broadcastStatus.text = getString(R.string.broadcast_off) - bind.broadcastStatus.setTextColor(muted) - } - !selected -> { - bind.broadcastStatus.text = getString(R.string.broadcast_wait) - bind.broadcastStatus.setTextColor(stop) - } - MockBus.lastError != null && !MockBus.lastOk -> { - bind.broadcastStatus.text = MockBus.lastError - bind.broadcastStatus.setTextColor(stop) - } - else -> { - bind.broadcastStatus.text = getString(R.string.broadcast_live) - bind.broadcastStatus.setTextColor(live) - } + val err = MockBus.lastError + val (status, color) = when { + !on -> getString(R.string.broadcast_off) to muted + !selected -> getString(R.string.broadcast_wait) to stop + err != null && !MockBus.lastOk -> err to stop + else -> getString(R.string.broadcast_live) to live } + bind.broadcastStatus.show(status) + if (bind.broadcastStatus.currentTextColor != color) bind.broadcastStatus.setTextColor(color) } private fun askIgnoreBattery() { @@ -427,7 +541,11 @@ class MainActivity : AppCompatActivity() { } private fun paintGo() { - if (engine.running) { + val running = engine.running + val label = getString(if (running) R.string.stop else R.string.go) + if (paintedRunning == running && bind.goBtn.text.toString() == label) return + paintedRunning = running + if (running) { bind.goBtn.setBackgroundResource(R.drawable.bg_stop) bind.goBtn.setTextColor(ContextCompat.getColor(this, R.color.stop_fg)) bind.goBtn.text = getString(R.string.stop) @@ -440,17 +558,24 @@ class MainActivity : AppCompatActivity() { private fun updateHud() { val p = engine.pos() - bind.hudCoords.text = formatPair(p) - bind.hudLabel.text = engine.label - bind.hudStatus.text = when { - engine.running && engine.paused -> getString(R.string.paused) - engine.running -> getString(R.string.live_signal) - else -> getString(R.string.idle_pin) - } - bind.hudMeta.text = "${headingLabel(engine.heading)} · ${"%.1f".format(engine.speedKmh)} km/h" + bind.hudCoords.show(formatPair(p)) + bind.hudLabel.show(engine.label) + bind.hudStatus.show( + when { + engine.running && engine.paused -> getString(R.string.paused) + engine.running -> getString(R.string.live_signal) + else -> getString(R.string.idle_pin) + }, + ) + bind.hudMeta.show("${headingLabel(engine.heading)} · ${"%.1f".format(engine.speedKmh)} km/h") paintSave() } + /** Only touch the view when the text really changes (this runs 20 times a second). */ + private fun TextView.show(value: CharSequence) { + if (text.toString() != value.toString()) text = value + } + private fun askPerms() { val need = mutableListOf( Manifest.permission.ACCESS_FINE_LOCATION, @@ -472,6 +597,8 @@ class MainActivity : AppCompatActivity() { } override fun onPause() { + val p = engine.pos() + if (hasChosen) LastPin.save(this, p.lat, p.lng, engine.label, force = true) bind.map.onPause() super.onPause() } @@ -479,6 +606,7 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { ui.removeCallbacks(tick) ui.removeCallbacks(searchDebounce) + if (MockBus.onStopped === gpsStopped) MockBus.onStopped = null super.onDestroy() } } diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt index ff52ebe..772dc75 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt @@ -41,24 +41,26 @@ class MockLocationService : Service() { override fun onCreate() { super.onCreate() + MockBus.restore(this) val nm = getSystemService(NotificationManager::class.java) nm.createNotificationChannel( NotificationChannel(CHANNEL, "TST Go GPS", NotificationManager.IMPORTANCE_LOW), ) try { - startForeground(1, notice()) + startForeground(NOTICE_ID, notice()) } catch (_: Exception) { } MockBus.running = true MockBus.prepare(this) val t = HandlerThread("tstgo-gps").also { it.start(); thread = it } handler = Handler(t.looper).also { it.post(pulse) } + StopWidget.refresh(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { MockBus.running = true try { - startForeground(1, notice()) + startForeground(NOTICE_ID, notice()) } catch (_: Exception) { } return START_STICKY @@ -71,33 +73,67 @@ class MockLocationService : Service() { thread = null MockBus.running = false MockBus.teardown(this) + StopWidget.refresh(this) + MockBus.onStopped?.invoke() super.onDestroy() } - private fun notice(): Notification { - val launch = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - return NotificationCompat.Builder(this, CHANNEL) - .setContentTitle("TST Go is changing your GPS") - .setContentText("Maps and other apps should follow this pin. Tap to open.") - .setSmallIcon(R.drawable.ic_stat_gps) - .setContentIntent(launch) - .setOngoing(true) - .build() - } + private fun notice(): Notification = buildNotice(this) companion object { private const val CHANNEL = "tstgo_mock" + private const val NOTICE_ID = 1 + const val ACTION_STOP = "com.thatsimpletech.tstgo.STOP" + fun start(ctx: Context) { ctx.startForegroundService(Intent(ctx, MockLocationService::class.java)) } + fun stop(ctx: Context) { ctx.stopService(Intent(ctx, MockLocationService::class.java)) } + + /** Re-posts the ongoing notification so it shows the current pin name. */ + fun refresh(ctx: Context) { + if (!MockBus.running) return + try { + ctx.getSystemService(NotificationManager::class.java) + ?.notify(NOTICE_ID, buildNotice(ctx)) + } catch (_: Exception) { + } + } + + /** Tapping this stops the fake GPS from anywhere: notification, widget, shortcut. */ + fun stopIntent(ctx: Context): PendingIntent = PendingIntent.getBroadcast( + ctx, + 1, + Intent(ctx, StopReceiver::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + /** Opens the app the same way the launcher icon does (resumes it if already open). */ + fun openIntent(ctx: Context): PendingIntent { + val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) + ?: Intent(ctx, MainActivity::class.java) + return PendingIntent.getActivity( + ctx, + 0, + launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun buildNotice(ctx: Context): Notification { + return NotificationCompat.Builder(ctx, CHANNEL) + .setContentTitle("TST Go is changing your GPS") + .setContentText("At ${MockBus.label}. Tap Stop to get your real GPS back.") + .setSmallIcon(R.drawable.ic_stat_gps) + .setContentIntent(openIntent(ctx)) + .addAction(R.drawable.ic_stat_stop, ctx.getString(R.string.stop_gps), stopIntent(ctx)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } } } @@ -107,17 +143,36 @@ object MockBus { @Volatile var lastOk = false @Volatile var lat = 40.758 @Volatile var lng = -73.9855 + @Volatile var label = "Times Square" @Volatile var heading = 0.0 @Volatile var speedKmh = 0.0 + /** True once a real pin is loaded (from the app or from disk), not the default. */ + @Volatile var hasFix = false + + /** Called on the main thread when the GPS service shuts down. */ + @Volatile var onStopped: (() -> Unit)? = null + private var fused: FusedLocationProviderClient? = null private var fusedReady = false - fun setFix(lat: Double, lng: Double, heading: Double, speedKmh: Double) { + fun setFix(lat: Double, lng: Double, heading: Double, speedKmh: Double, label: String? = null) { this.lat = lat this.lng = lng this.heading = heading this.speedKmh = speedKmh + if (label != null) this.label = label + hasFix = true + } + + /** + * After Android kills and restarts the app, memory is empty. Load the last + * saved pin so the service keeps broadcasting it instead of the default. + */ + fun restore(ctx: Context) { + if (hasFix) return + val saved = LastPin.load(ctx) ?: return + setFix(saved.lat, saved.lng, 0.0, 0.0, saved.label) } fun isSelectedMockApp(ctx: Context): Boolean { @@ -154,7 +209,8 @@ object MockBus { } fun inject(ctx: Context) { - if (!running) return + // Never broadcast the built-in default; only a pin someone actually set. + if (!running || !hasFix) return if (!isSelectedMockApp(ctx)) { lastOk = false lastError = "Select mock location app → TST Go (again after each install)." diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/StopReceiver.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/StopReceiver.kt new file mode 100644 index 0000000..d2e0e91 --- /dev/null +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/StopReceiver.kt @@ -0,0 +1,38 @@ +package com.thatsimpletech.tstgo + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast + +/** Handles the Stop button on the notification and the home screen widget. */ +class StopReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != MockLocationService.ACTION_STOP) return + stopFakeGps(context) + } +} + +/** Target of the "Stop GPS" app shortcut (long-press the icon, or drag it to the home screen). */ +class StopActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + stopFakeGps(this) + finish() + } +} + +/** Turns off the fake GPS so other apps see the real location again. */ +fun stopFakeGps(ctx: Context) { + val app = ctx.applicationContext + val wasRunning = MockBus.running + MockLocationService.stop(app) + StopWidget.refresh(app) + Toast.makeText( + app, + if (wasRunning) R.string.stopped_toast else R.string.already_stopped_toast, + Toast.LENGTH_SHORT, + ).show() +} diff --git a/android/app/src/main/java/com/thatsimpletech/tstgo/StopWidget.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/StopWidget.kt new file mode 100644 index 0000000..aaabba2 --- /dev/null +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/StopWidget.kt @@ -0,0 +1,55 @@ +package com.thatsimpletech.tstgo + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.widget.RemoteViews +import androidx.core.content.ContextCompat + +/** Home screen widget: shows whether TST Go is changing your GPS, with a Stop button. */ +class StopWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + render(context, manager, ids) + } + + companion object { + fun refresh(ctx: Context) { + try { + val manager = AppWidgetManager.getInstance(ctx) ?: return + val ids = manager.getAppWidgetIds(ComponentName(ctx, StopWidget::class.java)) + if (ids.isEmpty()) return + render(ctx, manager, ids) + } catch (_: Exception) { + } + } + + private fun render(ctx: Context, manager: AppWidgetManager, ids: IntArray) { + val live = MockBus.running + val views = RemoteViews(ctx.packageName, R.layout.widget_stop) + views.setTextViewText( + R.id.widgetStatus, + if (live) ctx.getString(R.string.widget_live, MockBus.label) else ctx.getString(R.string.widget_off), + ) + views.setTextViewText( + R.id.widgetAction, + ctx.getString(if (live) R.string.stop_gps else R.string.widget_open), + ) + views.setInt( + R.id.widgetAction, + "setBackgroundResource", + if (live) R.drawable.bg_stop else R.drawable.bg_go, + ) + views.setTextColor( + R.id.widgetAction, + ContextCompat.getColor(ctx, if (live) R.color.stop_fg else R.color.live_fg), + ) + views.setOnClickPendingIntent( + R.id.widgetAction, + if (live) MockLocationService.stopIntent(ctx) else MockLocationService.openIntent(ctx), + ) + views.setOnClickPendingIntent(R.id.widgetInfo, MockLocationService.openIntent(ctx)) + manager.updateAppWidget(ids, views) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_shortcut_stop.xml b/android/app/src/main/res/drawable/ic_shortcut_stop.xml new file mode 100644 index 0000000..33beacc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_stop.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_stop_fg.xml b/android/app/src/main/res/drawable/ic_shortcut_stop_fg.xml new file mode 100644 index 0000000..619dc72 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_stop_fg.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_stop.xml b/android/app/src/main/res/drawable/ic_stat_stop.xml new file mode 100644 index 0000000..a688005 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_stop.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 039ec2c..072d804 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -9,7 +9,23 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + - + android:layout_height="40dp" + android:layout_marginStart="8dp" + android:background="@drawable/bg_go" + android:gravity="center" + android:paddingHorizontal="16dp" + android:text="@string/start_gps" + android:textColor="@color/live_fg" + android:textSize="13sp" + android:textStyle="bold" /> + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9f17da3..ce6c650 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ TST Go Search a city, mall, address, or lat, lng Search + Couldn\'t find that. For a street address, include the city and state or the ZIP code. Idle pin Live signal Paused @@ -26,4 +27,14 @@ Removed from favorites No saved places yet. Jump somewhere, then tap Save. Remove + Stop GPS + Start GPS + Stop fake GPS + TST Go stopped. Your real GPS is back. + TST Go is already off. + TST Go stop button + Shows when TST Go is changing your GPS. Tap Stop to get your real GPS back. + Live at %1$s + Off. Apps see your real GPS. + Open diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..4782bfd --- /dev/null +++ b/android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/main/res/xml/widget_stop_info.xml b/android/app/src/main/res/xml/widget_stop_info.xml new file mode 100644 index 0000000..c537d20 --- /dev/null +++ b/android/app/src/main/res/xml/widget_stop_info.xml @@ -0,0 +1,14 @@ + + diff --git a/android/app/src/test/java/com/thatsimpletech/tstgo/AddressTest.kt b/android/app/src/test/java/com/thatsimpletech/tstgo/AddressTest.kt new file mode 100644 index 0000000..e3d6efc --- /dev/null +++ b/android/app/src/test/java/com/thatsimpletech/tstgo/AddressTest.kt @@ -0,0 +1,79 @@ +package com.thatsimpletech.tstgo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +class AddressTest { + @Test + fun stripsUnitNumbers() { + assertEquals("123 Main St, Springfield, IL 62701", cleanAddress("123 Main St Apt 4B, Springfield, IL 62701")) + assertEquals("123 Main St, Springfield IL", cleanAddress("123 Main St #12, Springfield IL")) + assertEquals("500 Oak Ave, Austin, TX", cleanAddress("500 Oak Ave, Unit 3, Austin, TX")) + assertEquals("42 Suite Rd, Dover, DE", cleanAddress("42 Suite Rd, Ste. 200, Dover, DE")) + assertEquals("77 Lotus Ln, Ocala FL", cleanAddress("77 Lotus Ln, Lot 9, Ocala FL")) + assertEquals("10 Floral Ave, Troy NY", cleanAddress("10 Floral Ave, Apt B, Troy NY")) + assertEquals("300 Unit Rd", cleanAddress("300 Unit Rd")) + assertEquals("Navy Pier", cleanAddress("Navy Pier")) + } + + @Test + fun spotsStreetAddresses() { + assertTrue(looksLikeStreetAddress("123 Main St, Springfield, IL")) + assertTrue(looksLikeStreetAddress("12B Baker St")) + assertTrue(looksLikeStreetAddress("8600 Rockville Pike 20894")) + assertFalse(looksLikeStreetAddress("Chicago")) + assertFalse(looksLikeStreetAddress("Navy Pier")) + } + + /** + * Hits the real search services, so it only runs when GEOCODE_LIVE=1 (CI sets it). + * Writes a side-by-side report: OpenStreetMap only (old) vs with the US Census (new). + */ + @Test + fun liveAddressSearch() { + assumeTrue(System.getenv("GEOCODE_LIVE") == "1") + val cases = listOf( + "4600 Silver Hill Rd, Washington, DC 20233", + "413 S 8th St, Springfield, IL 62701", + "339 N Oak Park Ave, Oak Park, IL 60302", + "951 Chicago Ave, Oak Park, IL 60302", + "3159 W 11th St, Cleveland, OH 44109", + "351 Farmington Ave, Hartford, CT 06105", + "3764 Elvis Presley Blvd, Memphis, TN 38116", + "1060 W Addison St, Chicago, IL 60613", + "1060 west addison street chicago il", + "8600 Rockville Pike 20894", + "600 Montgomery St Suite 5, San Francisco, CA", + "1 Microsoft Way #100, Redmond, WA", + "2 15th St NW, Washington, DC 20024", + "Navy Pier", + ) + val sb = StringBuilder("# Address search, live\n\n") + sb.append("| Query | Old (OpenStreetMap only) | New |\n|---|---|---|\n") + var newFound = 0 + for (q in cases) { + val old = runCatching { searchPlaces(q, useCensus = false).firstOrNull() }.getOrNull() + Thread.sleep(1200) + val new = runCatching { searchPlaces(q).firstOrNull() }.getOrNull() + Thread.sleep(1200) + if (new != null) newFound++ + sb.append("| ").append(q).append(" | ").append(describe(old)).append(" | ").append(describe(new)).append(" |\n") + } + sb.append("\nNew search found ").append(newFound).append(" of ").append(cases.size).append(".\n") + val out = File(System.getenv("GEOCODE_REPORT") ?: "build/geocode-report.md") + out.parentFile?.mkdirs() + out.writeText(sb.toString()) + println(sb) + } + + private fun describe(hit: PlaceHit?): String = + if (hit == null) { + "**not found**" + } else { + "${hit.name}, ${hit.detail} (${"%.5f".format(hit.lat)}, ${"%.5f".format(hit.lng)})".replace("|", "/") + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 017d909..3e8a55c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index b82aa23..e2847c8 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/package.json b/package.json index 3f627e3..673daac 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "preview:stop": "node scripts/preview.mjs stop", "typecheck": "tsc --noEmit", "check:auth": "node scripts/check-auth-invariant.mjs", - "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts", + "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/app-data/readiness-schedule.test.ts src/lib/auth/gate-identity.test.ts src/lib/auth/sign-in-gate.test.ts src/lib/geo/address.test.ts", "lint": "eslint .", "format": "prettier --write ." }, diff --git a/src/lib/geo/address.test.ts b/src/lib/geo/address.test.ts new file mode 100644 index 0000000..d423d96 --- /dev/null +++ b/src/lib/geo/address.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { cleanAddress, looksLikeStreetAddress, parseCensus } from "./address.ts"; + +test("strips apartment and suite numbers", () => { + assert.equal( + cleanAddress("123 Main St Apt 4B, Springfield, IL 62701"), + "123 Main St, Springfield, IL 62701", + ); + assert.equal(cleanAddress("123 Main St #12, Springfield IL"), "123 Main St, Springfield IL"); + assert.equal(cleanAddress("500 Oak Ave, Unit 3, Austin, TX"), "500 Oak Ave, Austin, TX"); + assert.equal(cleanAddress("42 Suite Rd, Ste. 200, Dover, DE"), "42 Suite Rd, Dover, DE"); + assert.equal(cleanAddress("77 Lotus Ln, Lot 9, Ocala FL"), "77 Lotus Ln, Ocala FL"); + assert.equal(cleanAddress("300 Unit Rd"), "300 Unit Rd"); + assert.equal(cleanAddress("Navy Pier"), "Navy Pier"); +}); + +test("spots street addresses", () => { + assert.ok(looksLikeStreetAddress("123 Main St, Springfield, IL")); + assert.ok(looksLikeStreetAddress("12B Baker St")); + assert.ok(!looksLikeStreetAddress("Chicago")); + assert.ok(!looksLikeStreetAddress("Navy Pier")); +}); + +test("reads a US Census geocoder response", () => { + const hits = parseCensus({ + result: { + addressMatches: [ + { + matchedAddress: "4600 SILVER HILL RD, WASHINGTON, DC, 20233", + coordinates: { x: -76.92748724230096, y: 38.84601622386617 }, + }, + ], + }, + }); + assert.equal(hits.length, 1); + assert.equal(hits[0]!.name, "4600 Silver Hill Rd"); + assert.equal(hits[0]!.detail, "Washington, DC, 20233"); + assert.ok(Math.abs(hits[0]!.lat - 38.846) < 0.001); + assert.ok(Math.abs(hits[0]!.lng + 76.9275) < 0.001); + assert.deepEqual(parseCensus({}), []); +}); diff --git a/src/lib/geo/address.ts b/src/lib/geo/address.ts new file mode 100644 index 0000000..903b3ee --- /dev/null +++ b/src/lib/geo/address.ts @@ -0,0 +1,71 @@ +// Helpers for street-address search. Pure functions so they can be unit tested. + +export type AddressHit = { name: string; detail: string; lat: number; lng: number }; + +// "Apt 4B", "Unit 3", "Ste. 200", "#12": every geocoder misses an address that includes them. +const UNIT_RE = + /(?:,\s*|\s+)(?:apt|apartment|unit|suite|ste|bldg|building|fl|floor|rm|room|lot)(?:\.\s*|\s+|\s*#\s*)(?:[a-z]?\d[a-z0-9-]*|[a-z])(?=,|\s|$)|\s*#\s*[a-z0-9-]+(?=,|\s|$)/gi; + +/** "123 Main St Apt 4B, Town" -> "123 Main St, Town". */ +export function cleanAddress(q: string): string { + return q + .replace(UNIT_RE, "") + .replace(/\s+/g, " ") + .replace(/\s+,/g, ",") + .trim() + .replace(/^,+|,+$/g, "") + .trim(); +} + +/** Starts with a house number followed by a street name, e.g. "123 Main St". */ +export function looksLikeStreetAddress(q: string): boolean { + return /^\d+[a-zA-Z]?(?:-\d+)?\s+[A-Za-z0-9]/.test(q.trim()); +} + +function titleCase(s: string): string { + return s + .toLowerCase() + .split(" ") + .map((w) => + ["ne", "nw", "se", "sw"].includes(w) + ? w.toUpperCase() + : w.charAt(0).toUpperCase() + w.slice(1), + ) + .join(" "); +} + +type CensusJson = { + result?: { + addressMatches?: Array<{ matchedAddress?: string; coordinates?: { x?: number; y?: number } }>; + }; +}; + +/** Turns a US Census geocoder response into hits ("4600 SILVER HILL RD, WASHINGTON, DC, 20233"). */ +export function parseCensus(json: CensusJson): AddressHit[] { + const out: AddressHit[] = []; + for (const m of json.result?.addressMatches ?? []) { + const lat = Number(m.coordinates?.y); + const lng = Number(m.coordinates?.x); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; + const parts = (m.matchedAddress ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const [street = "Address", city = "", ...rest] = parts; + out.push({ + name: titleCase(street), + detail: [titleCase(city), ...rest].filter(Boolean).join(", "), + lat, + lng, + }); + } + return out; +} + +export function censusUrl(q: string): URL { + const url = new URL("https://geocoding.geo.census.gov/geocoder/locations/onelineaddress"); + url.searchParams.set("address", q); + url.searchParams.set("benchmark", "Public_AR_Current"); + url.searchParams.set("format", "json"); + return url; +} diff --git a/src/lib/geo/geocode.ts b/src/lib/geo/geocode.ts index 5ca7e97..3c50144 100644 --- a/src/lib/geo/geocode.ts +++ b/src/lib/geo/geocode.ts @@ -1,5 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; +import { censusUrl, cleanAddress, looksLikeStreetAddress, parseCensus } from "./address"; export type PlaceHit = { name: string; @@ -19,41 +20,73 @@ function shortName(display: string, name?: string) { } function detailOf(display: string) { - const parts = display.split(",").map((s) => s.trim()).filter(Boolean); + const parts = display + .split(",") + .map((s) => s.trim()) + .filter(Boolean); return parts.slice(1, 4).join(", "); } export const searchPlaces = createServerFn({ method: "POST" }) .validator(z.object({ q: z.string().min(1).max(160) })) .handler(async ({ data }): Promise<{ results: PlaceHit[]; error?: string }> => { - const url = new URL("https://nominatim.openstreetmap.org/search"); - url.searchParams.set("format", "jsonv2"); - url.searchParams.set("q", data.q); - url.searchParams.set("limit", "7"); - url.searchParams.set("addressdetails", "0"); - try { - const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(8000) }); - if (!res.ok) return { results: [], error: "Search is busy. Try again in a moment." }; - const json = (await res.json()) as Array<{ - display_name: string; - name?: string; - lat: string; - lon: string; - }>; - const results: PlaceHit[] = json - .map((row) => ({ - name: shortName(row.display_name, row.name), - detail: detailOf(row.display_name), - lat: Number(row.lat), - lng: Number(row.lon), - })) - .filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lng)); - return { results }; - } catch { - return { results: [], error: "Could not reach the map search service." }; - } + const q = cleanAddress(data.q) || data.q.trim(); + // OpenStreetMap is missing many US house numbers; the US Census geocoder has nearly all of them. + const [census, osm] = await Promise.all([ + looksLikeStreetAddress(q) ? searchCensus(q) : Promise.resolve([]), + searchNominatim(q), + ]); + const seen = new Set(); + const results = [...census, ...(osm ?? [])].filter((r) => { + const key = `${r.lat.toFixed(4)},${r.lng.toFixed(4)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (results.length) return { results: results.slice(0, 7) }; + if (osm === null) return { results: [], error: "Could not reach the map search service." }; + return { results: [] }; }); +async function searchCensus(q: string): Promise { + try { + const res = await fetch(censusUrl(q), { headers: HEADERS, signal: AbortSignal.timeout(8000) }); + if (!res.ok) return []; + return parseCensus(await res.json()); + } catch { + return []; + } +} + +/** Returns null when the service could not be reached. */ +async function searchNominatim(q: string): Promise { + const url = new URL("https://nominatim.openstreetmap.org/search"); + url.searchParams.set("format", "jsonv2"); + url.searchParams.set("q", q); + url.searchParams.set("limit", "7"); + url.searchParams.set("addressdetails", "0"); + try { + const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(8000) }); + if (!res.ok) return null; + const json = (await res.json()) as Array<{ + display_name: string; + name?: string; + lat: string; + lon: string; + }>; + return json + .map((row) => ({ + name: shortName(row.display_name, row.name), + detail: detailOf(row.display_name), + lat: Number(row.lat), + lng: Number(row.lon), + })) + .filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lng)); + } catch { + return null; + } +} + export const reverseGeocode = createServerFn({ method: "POST" }) .validator(z.object({ lat: z.number(), lng: z.number() })) .handler(async ({ data }): Promise<{ label: string }> => { diff --git a/src/lib/geo/store.ts b/src/lib/geo/store.ts index 626e2c8..d5cb8c9 100644 --- a/src/lib/geo/store.ts +++ b/src/lib/geo/store.ts @@ -305,6 +305,9 @@ export const useFlashGo = create()( { name: "flashgo-v1", partialize: (s) => ({ + // Last broadcast point, so a reload (and the browser extension) stays put + // instead of snapping back to the default pin. + pick: { lat: (s.sim ?? s.pick).lat, lng: (s.sim ?? s.pick).lng, label: s.pick.label }, favorites: s.favorites, history: s.history, onboarded: s.onboarded,