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,