From 20bee4237ca66239ae04487b06bedfaa8b182b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:04:05 +0000 Subject: [PATCH 1/8] Remember the last pin and add a home-screen Stop GPS The chosen location only lived in memory, so when Android killed the app in the background (or you reopened it) the GPS service and the map fell back to the Times Square default. The web app had the same gap and pushed Times Square to the browser extension on every reload. Android: - Save the last pin to disk; the app and the GPS service restore it. - Stop GPS from outside the app: a button on the notification, a long-press icon shortcut (can be pinned to the home screen), and a home screen widget. The app screen updates when stopped this way. - Keep a single app screen so notification/widget taps don't start a second copy that broadcasts a different spot. - Version 1.0.8 (code 9). Web: persist the last broadcast point so reloads and the extension stay put. Share links still override it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- README.md | 10 +++ android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 26 ++++++ .../java/com/thatsimpletech/tstgo/LastPin.kt | 58 +++++++++++++ .../com/thatsimpletech/tstgo/MainActivity.kt | 40 ++++++++- .../tstgo/MockLocationService.kt | 86 +++++++++++++++---- .../com/thatsimpletech/tstgo/StopReceiver.kt | 38 ++++++++ .../com/thatsimpletech/tstgo/StopWidget.kt | 55 ++++++++++++ .../main/res/drawable/ic_shortcut_stop.xml | 5 ++ .../main/res/drawable/ic_shortcut_stop_fg.xml | 10 +++ .../src/main/res/drawable/ic_stat_stop.xml | 10 +++ .../app/src/main/res/layout/widget_stop.xml | 48 +++++++++++ android/app/src/main/res/values/strings.xml | 9 ++ android/app/src/main/res/xml/shortcuts.xml | 14 +++ .../app/src/main/res/xml/widget_stop_info.xml | 14 +++ src/lib/geo/store.ts | 3 + 16 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 android/app/src/main/java/com/thatsimpletech/tstgo/LastPin.kt create mode 100644 android/app/src/main/java/com/thatsimpletech/tstgo/StopReceiver.kt create mode 100644 android/app/src/main/java/com/thatsimpletech/tstgo/StopWidget.kt create mode 100644 android/app/src/main/res/drawable/ic_shortcut_stop.xml create mode 100644 android/app/src/main/res/drawable/ic_shortcut_stop_fg.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_stop.xml create mode 100644 android/app/src/main/res/layout/widget_stop.xml create mode 100644 android/app/src/main/res/xml/shortcuts.xml create mode 100644 android/app/src/main/res/xml/widget_stop_info.xml diff --git a/README.md b/README.md index e26568b..f6b44e0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ 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 it without opening the app** (gets your real GPS back): + +- 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. + ### Phone (browser app) 1. Open TST Go in **Safari** (iPhone / iPad) or **Chrome** (Android). @@ -104,6 +112,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..f9e9582 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.thatsimpletech.tstgo" minSdk = 26 targetSdk = 34 - versionCode = 8 - versionName = "1.0.7" + versionCode = 9 + versionName = "1.0.8" } signingConfigs { if (keystorePropertiesFile.exists()) { 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/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..3ca3d91 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt @@ -46,6 +46,8 @@ class MainActivity : AppCompatActivity() { private var line: Polyline? = null private var lastTick = SystemClockElapsed() private var searchJob: Job? = null + private var syncingSwitch = false + private val gpsStopped: () -> Unit = { ui.post { onGpsStopped() } } private val tick = object : Runnable { override fun run() { @@ -53,7 +55,8 @@ 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) + LastPin.save(this@MainActivity, p.lat, p.lng, engine.label) updateHud() marker?.position = GeoPoint(p.lat, p.lng) marker?.rotation = (-engine.heading).toFloat() @@ -75,6 +78,8 @@ class MainActivity : AppCompatActivity() { bind = ActivityMainBinding.inflate(layoutInflater) setContentView(bind.root) favs = FavoritesStore(this) + restoreLastPin() + MockBus.onStopped = gpsStopped askPerms() bind.map.setTileSource(EsriStreetTiles()) @@ -145,6 +150,7 @@ class MainActivity : AppCompatActivity() { bind.broadcastRow.setOnClickListener { MockBus.openDeveloperSettings(this) } bind.broadcastSwitch.setOnCheckedChangeListener { _, on -> + if (syncingSwitch) return@setOnCheckedChangeListener if (on) startBroadcast() else MockLocationService.stop(this) paintBroadcast() } @@ -153,12 +159,34 @@ class MainActivity : AppCompatActivity() { ui.post(tick) updateHud() val start = engine.pos() - MockBus.setFix(start.lat, start.lng, engine.heading, engine.speedKmh) + MockBus.setFix(start.lat, start.lng, engine.heading, engine.speedKmh, engine.label) bind.broadcastSwitch.isChecked = true paintBroadcast() paintSave() } + /** Comes back to the last pin instead of the built-in default (Times Square). */ + private fun restoreLastPin() { + val fromMemory = if (MockBus.hasFix) LastPin.Pin(MockBus.lat, MockBus.lng, MockBus.label) else null + val saved = fromMemory ?: LastPin.load(this) ?: return + engine.pick = LatLng(saved.lat, saved.lng) + engine.label = saved.label + } + + /** GPS was stopped from the notification, widget, or shortcut. Match the screen to it. */ + private fun onGpsStopped() { + if (isFinishing || isDestroyed || MockBus.running) return + if (bind.broadcastSwitch.isChecked) { + syncingSwitch = true + bind.broadcastSwitch.isChecked = false + syncingSwitch = false + engine.stop() + } + paintBroadcast() + paintGo() + updateHud() + } + private fun toggleSave() { val p = engine.pos() val nowSaved = favs.toggle(engine.label, p.lat, p.lng) @@ -372,10 +400,13 @@ 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) + MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh, name) + LastPin.save(this, p.lat, p.lng, name, force = true) hideResults() if (!MockBus.running) startBroadcast() bind.broadcastSwitch.isChecked = true + 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 @@ -472,6 +503,8 @@ class MainActivity : AppCompatActivity() { } override fun onPause() { + val p = engine.pos() + LastPin.save(this, p.lat, p.lng, engine.label, force = true) bind.map.onPause() super.onPause() } @@ -479,6 +512,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..3753ae5 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,62 @@ 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, + ) + + fun openIntent(ctx: Context): PendingIntent = PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, MainActivity::class.java), + 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 +138,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 { 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/widget_stop.xml b/android/app/src/main/res/layout/widget_stop.xml new file mode 100644 index 0000000..d45e99e --- /dev/null +++ b/android/app/src/main/res/layout/widget_stop.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9f17da3..0d76015 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -26,4 +26,13 @@ Removed from favorites No saved places yet. Jump somewhere, then tap Save. Remove + Stop 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/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, From 9ea423868a197ac7dabf9e38d309dc6f8fe3bda7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:04:05 +0000 Subject: [PATCH 2/8] Build the Android APK on GitHub Actions Compiles the app on every Android change and uploads a debug APK, so fixes can be checked and installed without a local Android SDK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/workflows/android.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..b3f43b7 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,34 @@ +name: Android build + +on: + push: + paths: + - "android/**" + - ".github/workflows/android.yml" + pull_request: + paths: + - "android/**" + - ".github/workflows/android.yml" + workflow_dispatch: + +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 + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon --stacktrace + - 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 From 9130050c4e25e5ff386619fcbf035fe1602e63ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:09:53 +0000 Subject: [PATCH 3/8] Target Android 16 and add a Google Play release workflow Google Play now requires app updates to target API 36, and the app targeted 34, so an upload would be rejected. - compileSdk/targetSdk 36, Android Gradle Plugin 8.9.1, Gradle 8.11.1. - Draw edge to edge on every Android version (15+ forces it) and pad the top and bottom controls by the system bar and notch insets, with solid strips behind the bars so it looks like before. - Notification and widget "open" taps use the launcher intent, so they resume the running app and match its intent filter. - CI now also builds the release bundle to catch release-only errors. - New manual workflow builds a signed .aab from repository secrets and can upload it to a Play track (internal by default; production is always a draft). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/workflows/android-release.yml | 92 +++++++++++++++++++ .github/workflows/android.yml | 6 +- README.md | 11 +++ android/app/build.gradle.kts | 4 +- .../com/thatsimpletech/tstgo/MainActivity.kt | 32 +++++++ .../tstgo/MockLocationService.kt | 17 ++-- .../app/src/main/res/layout/activity_main.xml | 17 ++++ android/build.gradle.kts | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 9 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/android-release.yml 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 index b3f43b7..af5b19f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -25,8 +25,10 @@ jobs: distribution: temurin java-version: "17" - uses: gradle/actions/setup-gradle@v4 - - name: Build debug APK - run: ./gradlew assembleDebug --no-daemon --stacktrace + - name: Build debug APK and release bundle + # The release bundle here is signed with the debug 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 - uses: actions/upload-artifact@v4 with: name: tst-go-debug-apk diff --git a/README.md b/README.md index f6b44e0..3e46800 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,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`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f9e9582..b3b0b8c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,11 +13,11 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.thatsimpletech.tstgo" - compileSdk = 34 + compileSdk = 36 defaultConfig { applicationId = "com.thatsimpletech.tstgo" minSdk = 26 - targetSdk = 34 + targetSdk = 36 versionCode = 9 versionName = "1.0.8" } 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 3ca3d91..5dfed9f 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 @@ -77,6 +81,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) bind = ActivityMainBinding.inflate(layoutInflater) setContentView(bind.root) + fitSystemBars() favs = FavoritesStore(this) restoreLastPin() MockBus.onStopped = gpsStopped @@ -165,6 +170,33 @@ class MainActivity : AppCompatActivity() { 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() { val fromMemory = if (MockBus.hasFix) LastPin.Pin(MockBus.lat, MockBus.lng, MockBus.label) else null 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 3753ae5..b1e4bdd 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt @@ -111,12 +111,17 @@ class MockLocationService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - fun openIntent(ctx: Context): PendingIntent = PendingIntent.getActivity( - ctx, - 0, - Intent(ctx, MainActivity::class.java), - 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) diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 039ec2c..a8fa74f 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" /> + + + + + Date: Fri, 30 Oct 2026 18:13:58 +0000 Subject: [PATCH 4/8] Add a Stop GPS button on the main screen and test on emulators - Replace the small broadcast switch with a clear Start GPS / Stop GPS button on the main screen. - Show the installed version in the header so it is obvious which build is on the phone. Version 1.0.9 (code 10). - Never broadcast the built-in Times Square pin: a fresh install leaves the real GPS alone until a spot is picked, the spot is only saved once one was chosen, and the service skips injecting until a pin is set. - Update screen text only when it changes instead of 20 times a second. - CI: run the APK on Android 11 and 15 emulators through the reported bugs (pick a spot, app killed, reopened, force-stopped, stop from the app, notification and shortcut) with screenshots, and publish the APK plus results to the ci/android-output branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .../scripts/__pycache__/ui.cpython-313.pyc | Bin 0 -> 2066 bytes .github/scripts/android-smoke.sh | 152 +++++++++++++++++ .github/scripts/ui.py | 39 +++++ .github/workflows/android.yml | 77 ++++++++- README.md | 6 +- android/app/build.gradle.kts | 4 +- .../com/thatsimpletech/tstgo/MainActivity.kt | 161 ++++++++++++------ .../tstgo/MockLocationService.kt | 3 +- .../app/src/main/res/layout/activity_main.xml | 15 +- android/app/src/main/res/values/strings.xml | 1 + 10 files changed, 394 insertions(+), 64 deletions(-) create mode 100644 .github/scripts/__pycache__/ui.cpython-313.pyc create mode 100755 .github/scripts/android-smoke.sh create mode 100755 .github/scripts/ui.py diff --git a/.github/scripts/__pycache__/ui.cpython-313.pyc b/.github/scripts/__pycache__/ui.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97324de2fbe6d3def13ac79f523b8483e73f4b59 GIT binary patch literal 2066 zcma)6O>EO<82u zOE(qJMgo%>4il+3(Bse!+qA=WnZ&dMw}~o~NWV!8X}k1R)XTKX_Bzz08!N+7zV~_G z?|J_nuX(*Zg2(gt_lxha2>oU&&cm&PwVxP-K0^tFQG!a)Cn-!-B#r5cWKOVIT0$pW zxJE)U9T%MdRheKCEQ>6NgE{QRyv)ZNs<#eGxDHa!aE0l?UMw~vxhCH1FsiZ@$(+NE zw^Z#8I=Bw7Pxi$_jZ>Z4*kP?=x$$2+PF2PJ9~+(G)8h8s5FxHoVbH{tLb zINN1ULWrD4Nbw^iH^iNCounWW9$B=@aq7fPr_X_xyW&*?UX8LCi9y{>r;p2G{H+bH z>(B`*H@@ulZD1dvg6lpi%o4Wv@_5_-FCK1^o1RC6+4X+|^}5$``92T!$#Uv z^mI0IraL|u;?H0`ZDu8NOsizx4N%EQ83U|RuysnBZVgG0Ubi_ACM-p#Y`kC6N2Ii2 zN+X7qR>S;?y{>^33eFW+3nEm6uxUfpW(2Hd4GRxz9lA;oVeKe09=i&twnNpj!-Uq= z88)J;`(~Qu_kyyzH}pp3l;8iCN@_l^(`8reFFAfF$kErDkdUqw9(N)!YX)llCT-Y93yNB$|bG|OT*K|ol&rf2)r?}p%47*WwV%+_@SExc*?AAG zK~O|omA8R#hoKECsU5`!z-sR!y9nnhLs8UImZjJ=9#Q-+i2oI}K18igUTe4=yA>;* zElw2rmymCcol|qsvTxh%z^%YyeaY8;MFg9Y^RLnjRaX|g^IbQ(3cWXvJ`!4%gqD)f zHrKn#A+K-#c>Z`{w%u$li(3m?K9y&li1qX1`SF{nqIq{_ zVdl>4lDPMgcDq)SP+-FIy>~_wU3rLZ%+MGD09L* zcY`bVY{=X2p|A6+-fv@H$G$s%AD0fCC~+s}x*rQ3a9#E{7VC@3-LZwSI~Pm-(3OF? zo*bR)DbUNTJJ(sD^M?u(`EapwkzP2oII$4^nGKcM+Uv2avE13~C!dCpw|R9BqS))) sRW9fISomXgKjM6Ah=Ms*dG==N{>=JdQ{PK{9D2Yt_U&XIv{7*W4MO_J9RL6T literal 0 HcmV?d00001 diff --git a/.github/scripts/android-smoke.sh b/.github/scripts/android-smoke.sh new file mode 100755 index 0000000..cc7bada --- /dev/null +++ b/.github/scripts/android-smoke.sh @@ -0,0 +1,152 @@ +#!/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.9* ]] && ok "header shows the version: $title" || bad "header shows '$title', expected 1.0.9" +[ "$(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 "### 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.yml b/.github/workflows/android.yml index af5b19f..eaade88 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,15 +2,18 @@ name: Android build on: push: + branches-ignore: + - "ci/**" paths: - "android/**" - ".github/workflows/android.yml" - pull_request: - 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 @@ -34,3 +37,69 @@ jobs: 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/README.md b/README.md index 3e46800..2a6b847 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,15 @@ 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 it without opening the app** (gets your real GPS back): +**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.9". + +**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. +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) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b3b0b8c..ba52b98 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.thatsimpletech.tstgo" minSdk = 26 targetSdk = 36 - versionCode = 9 - versionName = "1.0.8" + versionCode = 10 + versionName = "1.0.9" } signingConfigs { if (keystorePropertiesFile.exists()) { 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 5dfed9f..dae1e09 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt @@ -50,7 +50,15 @@ class MainActivity : AppCompatActivity() { private var line: Polyline? = null private var lastTick = SystemClockElapsed() private var searchJob: Job? = null - private var syncingSwitch = false + 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 { @@ -60,11 +68,16 @@ class MainActivity : AppCompatActivity() { lastTick = now val p = engine.pos() MockBus.setFix(p.lat, p.lng, engine.heading, engine.speedKmh, engine.label) - LastPin.save(this@MainActivity, p.lat, p.lng, 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) { @@ -83,8 +96,10 @@ class MainActivity : AppCompatActivity() { setContentView(bind.root) fitSystemBars() favs = FavoritesStore(this) - restoreLastPin() + val hadSavedPin = restoreLastPin() + hasChosen = hadSavedPin MockBus.onStopped = gpsStopped + showVersion() askPerms() bind.map.setTileSource(EsriStreetTiles()) @@ -154,10 +169,8 @@ class MainActivity : AppCompatActivity() { } bind.broadcastRow.setOnClickListener { MockBus.openDeveloperSettings(this) } - bind.broadcastSwitch.setOnCheckedChangeListener { _, on -> - if (syncingSwitch) return@setOnCheckedChangeListener - if (on) startBroadcast() else MockLocationService.stop(this) - paintBroadcast() + bind.broadcastBtn.setOnClickListener { + if (broadcastOn) stopFromApp() else setBroadcast(true) } lastTick = android.os.SystemClock.elapsedRealtime() @@ -165,8 +178,9 @@ class MainActivity : AppCompatActivity() { updateHud() val start = engine.pos() MockBus.setFix(start.lat, start.lng, engine.heading, engine.speedKmh, engine.label) - bind.broadcastSwitch.isChecked = true - paintBroadcast() + // 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() } @@ -198,20 +212,47 @@ class MainActivity : AppCompatActivity() { } /** Comes back to the last pin instead of the built-in default (Times Square). */ - private fun restoreLastPin() { - val fromMemory = if (MockBus.hasFix) LastPin.Pin(MockBus.lat, MockBus.lng, MockBus.label) else null - val saved = fromMemory ?: LastPin.load(this) ?: return + 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 (bind.broadcastSwitch.isChecked) { - syncingSwitch = true - bind.broadcastSwitch.isChecked = false - syncingSwitch = false + if (broadcastOn) { + broadcastOn = false engine.stop() } paintBroadcast() @@ -287,10 +328,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() { @@ -432,11 +477,13 @@ class MainActivity : AppCompatActivity() { engine.waypoints.add(engine.pos()) engine.waypoints.add(p) engine.teleport(p, name) + 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) @@ -453,29 +500,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() { @@ -490,7 +536,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) @@ -503,17 +553,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, @@ -536,7 +593,7 @@ class MainActivity : AppCompatActivity() { override fun onPause() { val p = engine.pos() - LastPin.save(this, p.lat, p.lng, engine.label, force = true) + if (hasChosen) LastPin.save(this, p.lat, p.lng, engine.label, force = true) bind.map.onPause() super.onPause() } 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 b1e4bdd..772dc75 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MockLocationService.kt @@ -209,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/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index a8fa74f..072d804 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -40,6 +40,7 @@ android:orientation="horizontal"> - + 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" /> 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. From e1ee04dafecea82e174b14f5528d7db6fb65db40 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:14:08 +0000 Subject: [PATCH 5/8] Drop Python cache file and ignore it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/scripts/__pycache__/ui.cpython-313.pyc | Bin 2066 -> 0 bytes .gitignore | 4 ++++ 2 files changed, 4 insertions(+) delete mode 100644 .github/scripts/__pycache__/ui.cpython-313.pyc diff --git a/.github/scripts/__pycache__/ui.cpython-313.pyc b/.github/scripts/__pycache__/ui.cpython-313.pyc deleted file mode 100644 index 97324de2fbe6d3def13ac79f523b8483e73f4b59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2066 zcma)6O>EO<82u zOE(qJMgo%>4il+3(Bse!+qA=WnZ&dMw}~o~NWV!8X}k1R)XTKX_Bzz08!N+7zV~_G z?|J_nuX(*Zg2(gt_lxha2>oU&&cm&PwVxP-K0^tFQG!a)Cn-!-B#r5cWKOVIT0$pW zxJE)U9T%MdRheKCEQ>6NgE{QRyv)ZNs<#eGxDHa!aE0l?UMw~vxhCH1FsiZ@$(+NE zw^Z#8I=Bw7Pxi$_jZ>Z4*kP?=x$$2+PF2PJ9~+(G)8h8s5FxHoVbH{tLb zINN1ULWrD4Nbw^iH^iNCounWW9$B=@aq7fPr_X_xyW&*?UX8LCi9y{>r;p2G{H+bH z>(B`*H@@ulZD1dvg6lpi%o4Wv@_5_-FCK1^o1RC6+4X+|^}5$``92T!$#Uv z^mI0IraL|u;?H0`ZDu8NOsizx4N%EQ83U|RuysnBZVgG0Ubi_ACM-p#Y`kC6N2Ii2 zN+X7qR>S;?y{>^33eFW+3nEm6uxUfpW(2Hd4GRxz9lA;oVeKe09=i&twnNpj!-Uq= z88)J;`(~Qu_kyyzH}pp3l;8iCN@_l^(`8reFFAfF$kErDkdUqw9(N)!YX)llCT-Y93yNB$|bG|OT*K|ol&rf2)r?}p%47*WwV%+_@SExc*?AAG zK~O|omA8R#hoKECsU5`!z-sR!y9nnhLs8UImZjJ=9#Q-+i2oI}K18igUTe4=yA>;* zElw2rmymCcol|qsvTxh%z^%YyeaY8;MFg9Y^RLnjRaX|g^IbQ(3cWXvJ`!4%gqD)f zHrKn#A+K-#c>Z`{w%u$li(3m?K9y&li1qX1`SF{nqIq{_ zVdl>4lDPMgcDq)SP+-FIy>~_wU3rLZ%+MGD09L* zcY`bVY{=X2p|A6+-fv@H$G$s%AD0fCC~+s}x*rQ3a9#E{7VC@3-LZwSI~Pm-(3OF? zo*bR)DbUNTJJ(sD^M?u(`EapwkzP2oII$4^nGKcM+Uv2avE13~C!dCpw|R9BqS))) sRW9fISomXgKjM6Ah=Ms*dG==N{>=JdQ{PK{9D2Yt_U&XIv{7*W4MO_J9RL6T 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 From a5872e50ad188e55d3ff960541dd0e9b448bb0de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:37:12 +0000 Subject: [PATCH 6/8] Find US street addresses in search OpenStreetMap, which both apps searched, is missing house numbers for many US homes, so typing a home address often found nothing. - Search button (Android) and web search also ask the US Census Bureau geocoder for anything that looks like a street address. It is free, needs no key, and covers nearly every US address. - Strip apartment and suite numbers ("Apt 4B", "#12", "Ste. 200") before searching; they made every lookup miss. - Android: search-as-you-type uses only Photon (biased toward the current pin), waits 450 ms instead of 280 ms, and no longer cancels a Search button lookup or reopens the list after picking a suggestion. - Clearer message when nothing is found. - Tests: address cleanup unit tests (Kotlin and web), a live CI report comparing old and new search on real addresses, and an emulator step that searches an address with an apartment number. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/scripts/android-smoke.sh | 13 +++ .github/workflows/android.yml | 14 +++ android/app/build.gradle.kts | 4 + .../main/java/com/thatsimpletech/tstgo/Geo.kt | 99 +++++++++++++++++-- .../com/thatsimpletech/tstgo/MainActivity.kt | 11 ++- android/app/src/main/res/values/strings.xml | 1 + .../com/thatsimpletech/tstgo/AddressTest.kt | 79 +++++++++++++++ package.json | 2 +- src/lib/geo/address.test.ts | 42 ++++++++ src/lib/geo/address.ts | 71 +++++++++++++ src/lib/geo/geocode.ts | 87 +++++++++++----- 11 files changed, 382 insertions(+), 41 deletions(-) create mode 100644 android/app/src/test/java/com/thatsimpletech/tstgo/AddressTest.kt create mode 100644 src/lib/geo/address.test.ts create mode 100644 src/lib/geo/address.ts diff --git a/.github/scripts/android-smoke.sh b/.github/scripts/android-smoke.sh index cc7bada..5971912 100755 --- a/.github/scripts/android-smoke.sh +++ b/.github/scripts/android-smoke.sh @@ -139,6 +139,19 @@ 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" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index eaade88..bec1d89 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -32,6 +32,20 @@ jobs: # The release bundle here is signed with the debug 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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ba52b98..6624c9a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,4 +61,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/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/MainActivity.kt b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt index dae1e09..a74c0d9 100644 --- a/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt +++ b/android/app/src/main/java/com/thatsimpletech/tstgo/MainActivity.kt @@ -129,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 @@ -139,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() } @@ -423,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() } @@ -435,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 } @@ -457,6 +461,7 @@ class MainActivity : AppCompatActivity() { goTo(LatLng(hit.lat, hit.lng), hit.name) hideResults() bind.search.setText(hit.name) + ui.removeCallbacks(searchDebounce) hideKeyboard() } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 07582c2..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 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/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 }> => { From 94fd2d11f6f90e3ba73a329751d0480c0f5f87cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:37:35 +0000 Subject: [PATCH 7/8] Keep one test signing key across CI builds; version 1.0.10 Each CI run made a new debug key, so every test APK needed the previous one uninstalled first. Cache the key so later builds update in place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/scripts/android-smoke.sh | 2 +- .github/workflows/android.yml | 7 +++++++ README.md | 2 +- android/app/build.gradle.kts | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/scripts/android-smoke.sh b/.github/scripts/android-smoke.sh index 5971912..f626be8 100755 --- a/.github/scripts/android-smoke.sh +++ b/.github/scripts/android-smoke.sh @@ -53,7 +53,7 @@ log "### 1. Fresh install" open_app shot 01-fresh-install title=$(ui text id:appTitle) -[[ "$title" == *1.0.9* ]] && ok "header shows the version: $title" || bad "header shows '$title', expected 1.0.9" +[[ "$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" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index bec1d89..01387d5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,6 +28,13 @@ jobs: distribution: temurin java-version: "17" - uses: gradle/actions/setup-gradle@v4 + # Keep one debug signing key across runs so each new test APK installs over the + # last one. (Without this, every run makes a new key and Android refuses the update.) + - name: Reuse the test signing key + uses: actions/cache@v4 + with: + path: ~/.android/debug.keystore + key: tstgo-debug-keystore-v1 - name: Build debug APK and release bundle # The release bundle here is signed with the debug key; it only checks that a # release build (including Play's lint checks) succeeds. See android-release.yml. diff --git a/README.md b/README.md index 2a6b847..bda2541 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ 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.9". +**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**: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6624c9a..f6acc9e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.thatsimpletech.tstgo" minSdk = 26 targetSdk = 36 - versionCode = 10 - versionName = "1.0.9" + versionCode = 11 + versionName = "1.0.10" } signingConfigs { if (keystorePropertiesFile.exists()) { From 9c3378018c64dcbb2ab75a1da6f24c6b19fa59a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:49:17 +0000 Subject: [PATCH 8/8] Sign CI test APKs with one saved key The cache step pointed at ~/.android/debug.keystore, which the runner never creates, so nothing was saved. Create the test key at a known path (or take it from the optional TSTGO_TEST_KEYSTORE_BASE64 secret), keep it in the Actions cache, and point the debug signing config at it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCNTkBuCdnW1vT6dVf --- .github/workflows/android.yml | 32 ++++++++++++++++++++++++++------ android/app/build.gradle.kts | 11 +++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 01387d5..29da3a5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,15 +28,35 @@ jobs: distribution: temurin java-version: "17" - uses: gradle/actions/setup-gradle@v4 - # Keep one debug signing key across runs so each new test APK installs over the - # last one. (Without this, every run makes a new key and Android refuses the update.) - - name: Reuse the test signing key + # 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: ~/.android/debug.keystore - key: tstgo-debug-keystore-v1 + 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 debug key; it only checks that a + # 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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f6acc9e..da838e8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,6 +22,17 @@ android { 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")