Skip to content

feat(chat): run live location natively so it works without JS - #29655

Draft
chrisnojima wants to merge 7 commits into
nojima/HOTPOT-as-06-androidfrom
nojima/HOTPOT-as-07-livelocation
Draft

chrisnojima wants to merge 7 commits into
nojima/HOTPOT-as-06-androidfrom
nojima/HOTPOT-as-07-livelocation

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Stack 7/9, splitting #29637. Parent: #29654.

Why

Live location is a background feature that depends on JS being alive. On iOS JS is exactly what the OS stops running when the app is backgrounded, so the feature degrades precisely when it is supposed to work. Moving the watch into native means the OS location service keeps reporting fixes whether or not JS is running.

Master bugs this fixes

  • The chat-UI watch retry loop can never exit. livelocation.go:240-251 sets maxWatchAttempts := 20 and watchAttempts := 0, tests watchAttempts > maxWatchAttempts, and then increments maxWatchAttempts++. The counter under test never moves while its limit grows, so the loop spins forever and the return 0, err below it is dead code. A logout mid-retry waits on it.
  • lastCoord is read without the lock from tracker() and updateMapUnfurl() while another goroutine writes it — a real data race.
  • Stop can race a concurrently-starting tracker, waiting on an errgroup a new tracker may still be adding to.
  • Live location stops working when JS is not running, which on iOS is most of the time it matters.

What this changes

A NativeLocationWatcher is threaded through Init/InitOnce; iOS implements it with LocationWatcher.swift driving CLLocationManager and reporting each fix through LocationUpdate. Android passes nil and keeps the chat-UI watch, because it does not have the same JS-suspension problem. Native watches are ref-counted across trackers so several trackers share one OS watch. The distance throttle moves into Go, so the policy lives in one place instead of in each platform. A background fix is recorded once it lies, in a straight line from the last recorded fix, at least both fixes' reported accuracies added together, clamped between 65 m and 200 m. A fix less than half as uncertain as the last recorded one is also recorded, so a coarse cold fix gets replaced once the device locks on.

The three bugs above are fixed in passing: the retry counter is corrected (which is what first makes the error path reachable at all), lastCoord goes behind an accessor, and each run gets its own errgroup.

Closes a gap #29651 opened

#29651 removed the UpdateWithCheck CAS without a replacement, so background live location went dark once the UIBackground hold released — knowingly accepted at the time. The hold is restored here as bgHold on the tracker: opened on a fix while trackers exist, released when the last one goes, and re-acquired if the controller ended it at WillTerminate. Four new tests cover it. This is why #29651 must not merge without this PR following.

Decision for review: the background fix distance

The iOS watcher asks for kCLLocationAccuracyHundredMeters with no distance filter, so fixes arrive roughly once a second and jitter by tens of metres. Adding up fix-to-fix distance let a stationary phone's jitter pass 65 m within a few fixes, recording and posting points that didn't move. Measuring straight-line distance from the last recorded fix, and requiring it to exceed both fixes' accuracies combined, stops that. It also handles a jittered outlier used as the anchor.

The 200 m cap is a deliberate tradeoff. Without it, typical 65–100 m fixes need about 200 m of movement anyway, but Approximate Location (accuracy of about 1–5 km on every fix) would need kilometres and effectively stop background updates. With the cap, updates never stall, at the cost that jitter from fixes coarser than about 100 m can occasionally count as a move.

Verification

go build, go vet, gofmt, golangci-lint --new-from-rev master 0 issues. chat/maps tests pass including under -race. Android builds — javap on the rebuilt AAR confirms the 13-param init and the NativeLocationWatcher interface. iOS builds. yarn lint:all clean, unit suite green.

Worth a look in review: LocationWatcher.swift sets pausesLocationUpdatesAutomatically = true with no locationManagerDidPauseLocationUpdates delegate, so after a pause it silently degrades to significant-change granularity. That is parity with what expo-location was doing before and matches #29637, but it deserves a conscious decision rather than inheritance.

TestTrackStorage needs kbweb on localhost:3000 and could not run here; it is pre-existing and untouched.

@chrisnojima
chrisnojima added this pull request to stack #29658 September 21, 2026 18:56
@chrisnojima chrisnojima changed the title nojima/HOTPOT as 07 livelocation feat(chat): run live location natively so it works without JS Sep 21, 2026
… fix

Live location ran entirely through the chat UI: the service asked JS to
watch position and JS relayed every fix back over RPC. With no JS, there
was no location. startWatch now splits -- when a native LocationWatcher is
wired up it starts the OS watch itself (ref-counted across trackers, so one
watch serves them all) and only asks the chat UI for the permission prompt
it cannot raise on its own. NativeLocationUpdate takes every fix the
watcher reports and shouldRecordFix decides which to keep: in the
foreground all of them, out of it only once the device has moved 65m since
the last recorded one, measured with a haversine over the fix-to-fix path
so a slow drift still adds up.

This also restores what the lifecycle rework left open. A fix can wake a
backgrounded app, and the update needs to get out before iOS suspends it
again, so a fix opens a background-work hold; releaseHoldIfIdleLocked ends
it once the last tracker is gone. Nothing writes MobileAppState directly
any more.

Three bugs fixed along the way. The chat-UI watch retry loop incremented
maxWatchAttempts where it meant watchAttempts, so it never terminated. Its
sleep went through time.Sleep instead of the injected clock, so it could
not be tested. And lastCoord was read without the lock from tracker() and
updateMapUnfurl(); it now goes through getLastCoord(). The errgroup is also
per-run now, so Stop can't end up waiting on a tracker that started after
it.
Init and InitOnce take a NativeLocationWatcher, which iOS supplies and
Android passes nil for -- Android keeps the expo background location task.
LocationWatcher.swift owns a CLLocationManager with the same options expo
used, started and stopped by Go and reporting every fix back through
LocationUpdate off the main thread. It is built in didFinishLaunching,
before Go restores its trackers, so an app relaunched by significant-change
monitoring picks the watch back up.

LocationUpdate drops fixes taken before Init finishes or while logged out,
since there is no tracker to take them.
onChatWatchPosition still asks for the permission the native watcher can't
prompt for, then stops: on iOS the OS watch is Go's now, and running the
expo background location task alongside it would mean two CLLocationManagers
reporting the same fixes. Android is unchanged.

Builds from before this left the expo task registered, and expo restores it
into a second manager on every launch, so JS unregisters it once at startup.
It is early enough to matter: with no UMAppLoader registered, expo can never
start JS for a restored task on a background launch.
The background throttle summed fix-to-fix distance and recorded a fix
once that sum reached 65m. The iOS watcher asks for hundred-meter
accuracy with no distance filter, so a phone sitting still reports fixes
that jitter by tens of meters, and their sum passed 65m within a few
fixes. Each one was recorded, saved and posted as a move that never
happened, and kept the device doing that work while it sat still.

Measure the straight-line distance from the last recorded fix instead.
Jitter around one spot stays inside the radius, while a slow drift still
builds displacement and gets recorded once it covers the distance.
…uracy

The background throttle measured each fix against the last recorded one,
but that anchor can itself be an outlier: a cold fix 40m off lets jitter
on the other side clear 65m and become the new anchor, and a stationary
phone keeps posting.

Require a background fix to lie at least max(65m, anchor accuracy + next
accuracy) from the anchor, so two fixes whose accuracy circles overlap
never count as a move. Also record a fix less than half as uncertain as
the anchor, so a coarse cold fix is replaced once the device locks on
rather than holding the threshold wide open. An accuracy of 0 (unknown)
never counts as an improvement.
A background fix had to move at least both fixes' accuracies added
together. The iOS watcher asks for hundred-meter accuracy, and with
Approximate Location every fix is kilometres wide, so the threshold grew
to several kilometres and a drive across town recorded nothing.

Cap the threshold at 200m. Realistic 65-100m accuracies stay under the
cap, so jitter around an outlier anchor is still ignored.
@chrisnojima
chrisnojima force-pushed the nojima/HOTPOT-as-07-livelocation branch from 557552f to c4264e2 Compare September 22, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant