feat(chat): run live location natively so it works without JS - #29655
Draft
chrisnojima wants to merge 7 commits into
Draft
chrisnojima wants to merge 7 commits into
chrisnojima wants to merge 7 commits into
Conversation
chrisnojima
added this pull request to stack #29658
September 21, 2026 18:56
This was referenced 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
force-pushed
the
nojima/HOTPOT-as-07-livelocation
branch
from
September 22, 2026 23:08
557552f to
c4264e2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
livelocation.go:240-251setsmaxWatchAttempts := 20andwatchAttempts := 0, testswatchAttempts > maxWatchAttempts, and then incrementsmaxWatchAttempts++. The counter under test never moves while its limit grows, so the loop spins forever and thereturn 0, errbelow it is dead code. A logout mid-retry waits on it.lastCoordis read without the lock fromtracker()andupdateMapUnfurl()while another goroutine writes it — a real data race.Stopcan race a concurrently-starting tracker, waiting on an errgroup a new tracker may still be adding to.What this changes
A
NativeLocationWatcheris threaded throughInit/InitOnce; iOS implements it withLocationWatcher.swiftdrivingCLLocationManagerand reporting each fix throughLocationUpdate. 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),
lastCoordgoes behind an accessor, and each run gets its own errgroup.Closes a gap #29651 opened
#29651 removed the
UpdateWithCheckCAS without a replacement, so background live location went dark once the UIBackground hold released — knowingly accepted at the time. The hold is restored here asbgHoldon the tracker: opened on a fix while trackers exist, released when the last one goes, and re-acquired if the controller ended it atWillTerminate. 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
kCLLocationAccuracyHundredMeterswith 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 master0 issues.chat/mapstests pass including under-race. Android builds —javapon the rebuilt AAR confirms the 13-paraminitand theNativeLocationWatcherinterface. iOS builds.yarn lint:allclean, unit suite green.Worth a look in review:
LocationWatcher.swiftsetspausesLocationUpdatesAutomatically = truewith nolocationManagerDidPauseLocationUpdatesdelegate, 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.TestTrackStorageneeds kbweb onlocalhost:3000and could not run here; it is pre-existing and untouched.