From 6ca2b083cf44ddce59430feea6246e7288b20043 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 23 Sep 2026 13:36:00 -0400 Subject: [PATCH 1/4] feat(ios): native location watch relayed through JS iOS live location runs on a CLLocationManager in the react-native-kb module instead of the expo-location background task. JS still answers the service's chatWatchPosition/chatClearWatch, refcounts the native watch, and forwards fixes with localLocationUpdate. Native only applies a 65 m distanceFilter and emits every fix; JS throttles out of the foreground (a move must exceed both fixes' accuracies summed, clamped to 65-200 m, or be a fix more than twice as sharp), restarting the throttle with each watch. The throttle's jest table ports the go/chat/maps throttle cases. The legacy expo task is unregistered at startup so expo stops restoring a second location manager. Android keeps the expo-location path. --- .../main/java/com/reactnativekb/KbModule.kt | 7 + rnmodules/react-native-kb/ios/Kb.mm | 29 ++ .../react-native-kb/ios/KbLocationWatcher.h | 12 + .../react-native-kb/ios/KbLocationWatcher.m | 84 ++++++ .../react-native-kb/react-native-kb.podspec | 1 + rnmodules/react-native-kb/src/NativeKb.ts | 5 + rnmodules/react-native-kb/src/index.tsx | 20 ++ shared/constants/init/index.tsx | 73 +++++- .../constants/init/location-throttle.test.ts | 197 ++++++++++++++ shared/constants/init/location-throttle.ts | 42 +++ shared/constants/init/location-watch.test.ts | 248 ++++++++++++++++++ shared/constants/init/platform-types.ts | 7 + shared/constants/init/platform.native.tsx | 6 + shared/ios/Podfile.lock | 2 +- 14 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 rnmodules/react-native-kb/ios/KbLocationWatcher.h create mode 100644 rnmodules/react-native-kb/ios/KbLocationWatcher.m create mode 100644 shared/constants/init/location-throttle.test.ts create mode 100644 shared/constants/init/location-throttle.ts create mode 100644 shared/constants/init/location-watch.test.ts diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index 91e352586b31..80d538b363cb 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -76,6 +76,13 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T } } + // iOS only: Android watches location through expo-location in JS + @ReactMethod + override fun startLocationWatch() {} + + @ReactMethod + override fun stopLocationWatch() {} + @ReactMethod override fun clearLocalLogs(promise: Promise) { promise.resolve(null) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index ce1963e66423..3f53901c076e 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -1,4 +1,5 @@ #import "Kb.h" +#import "KbLocationWatcher.h" #import "Keybasego.h" #import "engine-reset-backoff.h" #import @@ -916,6 +917,34 @@ + (void)emitAppLifecycle:(NSString *)state { return kbAppLifecycleState; } +// One per process, so a JS reload neither leaks a second CLLocationManager nor +// stops a running watch. ++ (KbLocationWatcher *)locationWatcher { + static KbLocationWatcher *watcher = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + watcher = [[KbLocationWatcher alloc] initWithOnFix:^(CLLocation *location) { + Kb *instance = kbSharedInstance; + if (instance && [instance canEmit]) { + [instance emitOnLocationFix:@{ + @"lat" : @(location.coordinate.latitude), + @"lon" : @(location.coordinate.longitude), + @"accuracy" : @(location.horizontalAccuracy), + }]; + } + }]; + }); + return watcher; +} + +RCT_EXPORT_METHOD(startLocationWatch) { + [[Kb locationWatcher] start]; +} + +RCT_EXPORT_METHOD(stopLocationWatch) { + [[Kb locationWatcher] stop]; +} + + (void)emitPushNotification:(NSDictionary *)notification { Kb *instance = kbSharedInstance; if (instance && [instance canEmit]) { diff --git a/rnmodules/react-native-kb/ios/KbLocationWatcher.h b/rnmodules/react-native-kb/ios/KbLocationWatcher.h new file mode 100644 index 000000000000..d377597d1a2e --- /dev/null +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.h @@ -0,0 +1,12 @@ +#import +#import + +// Runs the OS location service for live location sharing. start and stop may be +// called from any thread; onFix runs on the main thread for every fix with a +// valid accuracy, unthrottled past the distance filter (JS decides which to +// record). +@interface KbLocationWatcher : NSObject +- (instancetype)initWithOnFix:(void (^)(CLLocation *location))onFix; +- (void)start; +- (void)stop; +@end diff --git a/rnmodules/react-native-kb/ios/KbLocationWatcher.m b/rnmodules/react-native-kb/ios/KbLocationWatcher.m new file mode 100644 index 000000000000..cff337936b4d --- /dev/null +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.m @@ -0,0 +1,84 @@ +#import "KbLocationWatcher.h" + +@implementation KbLocationWatcher { + // Everything below is main thread only; the manager delivers its delegate + // callbacks on the run loop of the thread that created it. + CLLocationManager *_manager; + BOOL _wanted; + BOOL _running; + void (^_onFix)(CLLocation *); +} + +- (instancetype)initWithOnFix:(void (^)(CLLocation *))onFix { + self = [super init]; + _onFix = [onFix copy]; + return self; +} + +- (void)start { + dispatch_async(dispatch_get_main_queue(), ^{ + self->_wanted = YES; + [self apply]; + }); +} + +- (void)stop { + dispatch_async(dispatch_get_main_queue(), ^{ + self->_wanted = NO; + [self apply]; + }); +} + +// JS asks for the permission before starting; once the user answers, this +// starts watching if it is still wanted. +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { + [self apply]; +} + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + if (!_running) return; + for (CLLocation *location in locations) { + if (location.horizontalAccuracy >= 0) { + _onFix(location); + } + } +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + NSLog(@"KbLocationWatcher: location update failed: %@", error.localizedDescription); +} + +- (void)apply { + if (!_manager) { + if (!_wanted) return; + _manager = [CLLocationManager new]; + _manager.delegate = self; + } + CLAuthorizationStatus status = _manager.authorizationStatus; + BOOL authorized = + status == kCLAuthorizationStatusAuthorizedAlways || status == kCLAuthorizationStatusAuthorizedWhenInUse; + if (_wanted && !authorized) { + NSLog(@"KbLocationWatcher: not watching location: not authorized (status %d)", (int)status); + } + if (_wanted && authorized && !_running) { + NSLog(@"KbLocationWatcher: starting location updates"); + _running = YES; + // Needs the `location` UIBackgroundModes entry, or this throws. + _manager.allowsBackgroundLocationUpdates = YES; + _manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + // The throttle's floor; JS applies the rest of it. + _manager.distanceFilter = 65; + _manager.activityType = CLActivityTypeOther; + _manager.pausesLocationUpdatesAutomatically = YES; + _manager.showsBackgroundLocationIndicator = YES; + [_manager startUpdatingLocation]; + [_manager startMonitoringSignificantLocationChanges]; + } else if (_running && !(_wanted && authorized)) { + NSLog(@"KbLocationWatcher: stopping location updates"); + _running = NO; + [_manager stopUpdatingLocation]; + [_manager stopMonitoringSignificantLocationChanges]; + } +} + +@end diff --git a/rnmodules/react-native-kb/react-native-kb.podspec b/rnmodules/react-native-kb/react-native-kb.podspec index ac00d452a853..336f43002769 100644 --- a/rnmodules/react-native-kb/react-native-kb.podspec +++ b/rnmodules/react-native-kb/react-native-kb.podspec @@ -25,6 +25,7 @@ Pod::Spec.new do |s| s.exclude_files = "cpp/tests/**/*" s.dependency "KBCommon" + s.frameworks = "CoreLocation" # Kb.mm calls into keybasego.xcframework, which is a gitignored build artifact # and so never arrives with a branch. Without this, checking out a branch that diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index c0c91004c8e8..8665f22fca8f 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -10,6 +10,8 @@ export interface Spec extends TurboModule { readonly onShareData: EventEmitter<{text?: string; localPaths?: Array}> // 'active' | 'inactive' | 'background', sent from the callbacks that report the state to Go readonly onAppLifecycle: EventEmitter<{state: string}> + // iOS only: every fix the location watch receives, accuracy in metres + readonly onLocationFix: EventEmitter<{lat: number; lon: number; accuracy: number}> getTypedConstants(): { androidIsDeviceSecure: boolean androidIsTestDevice: boolean @@ -72,6 +74,9 @@ export interface Spec extends TurboModule { clearLocalLogs(): Promise // the last state onAppLifecycle carried, including one sent before JS listened getAppLifecycleState(): string + // iOS only. Idempotent; the watch keeps running in the background until stopped. + startLocationWatch(): void + stopLocationWatch(): void } export default TurboModuleRegistry.getEnforcing('Kb') diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index 4d7fcd36104a..8b2e8467026f 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -169,6 +169,26 @@ export const getAppLifecycleState = (): AppLifecycleState => { return Kb.getAppLifecycleState() as AppLifecycleState } +// iOS only +export const startLocationWatch = (): void => { + if (Platform.OS === 'ios') { + Kb.startLocationWatch() + } +} + +export const stopLocationWatch = (): void => { + if (Platform.OS === 'ios') { + Kb.stopLocationWatch() + } +} + +export const addLocationFixListener = ( + callback: (fix: {lat: number; lon: number; accuracy: number}) => void +): (() => void) => { + const sub = Kb.onLocationFix(callback) + return () => sub.remove() +} + export const engineReset = (): void => { return Kb.engineReset() } diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index d274658dd549..5688ee5e5154 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -29,6 +29,7 @@ import { setupWindowEventListeners, } from './platform' import type {ExpoLocationObject, ExpoTaskManagerModule} from './platform-types' +import {shouldRecordFix, type Fix, type FixThrottle} from './location-throttle' import {openAtLoginKey} from '@/stores/shell' import {useDarkModeState} from '@/stores/darkmode' import * as ScreenCapture from 'expo-screen-capture' @@ -66,6 +67,52 @@ const ensureBackgroundTask = (ExpoTaskManager: ExpoTaskManagerModule) => { }) } +// Builds from before native iOS location left this expo task registered, and expo restores it +// into a second CLLocationManager on every launch. JS is early enough to remove it: with no +// UMAppLoader registered, expo can never start JS for a restored task on a background launch +// (expo-task-manager EXTaskService.m `_loadAppWithId:appUrl:`). +export const unregisterLegacyIOSLocationTask = async () => { + if (!isIOS) return + const {ExpoTaskManager} = _getNative() + try { + if (await ExpoTaskManager.isTaskRegisteredAsync(locationTaskName)) { + await ExpoTaskManager.unregisterTaskAsync(locationTaskName) + logger.info('[location] removed the legacy iOS background location task') + } + } catch (error) { + logger.info('[location] failed to remove the legacy iOS background location task: ' + String(error)) + } +} + +// iOS: the native watcher sends every fix past its 65 m distance filter; the throttle picks the +// ones worth recording. +let removeFixListener: (() => void) | undefined +let fixThrottle: FixThrottle = {} + +const onLocationFix = (fix: Fix) => { + const {record, throttle} = shouldRecordFix(useShellState.getState().mobileAppState, fixThrottle, fix) + fixThrottle = throttle + if (!record) return + const coord = {accuracy: Math.floor(fix.accuracy), lat: fix.lat, lon: fix.lon} + T.RPCChat.localLocationUpdateRpcPromise({coord}).catch((error: unknown) => { + logger.info('location update failed: ' + String(error)) + }) +} + +const startIOSLocationWatch = () => { + const {addLocationFixListener, startLocationWatch} = _getNative() + startLocationWatch() + fixThrottle = {} + removeFixListener = addLocationFixListener(onLocationFix) +} + +const stopIOSLocationWatch = () => { + const {stopLocationWatch} = _getNative() + stopLocationWatch() + removeFixListener?.() + removeFixListener = undefined +} + const setPermissionDeniedCommandStatus = (conversationIDKey: T.Chat.ConversationIDKey, text: string) => { setThreadInputCommandStatus(conversationIDKey, { actions: [T.RPCChat.UICommandStatusActionTyp.appsettings], @@ -97,12 +144,16 @@ const onChatWatchPosition = async ( if (locationRefs === 1) { try { logger.info('[location] location watch start due to ', T.Chat.conversationIDToKey(action.payload.params.convID)) - ensureBackgroundTask(ExpoTaskManager) - await ExpoLocation.startLocationUpdatesAsync(locationTaskName, { - deferredUpdatesDistance: 65, - pausesUpdatesAutomatically: true, - showsBackgroundLocationIndicator: true, - }) + if (isIOS) { + startIOSLocationWatch() + } else { + ensureBackgroundTask(ExpoTaskManager) + await ExpoLocation.startLocationUpdatesAsync(locationTaskName, { + deferredUpdatesDistance: 65, + pausesUpdatesAutomatically: true, + showsBackgroundLocationIndicator: true, + }) + } logger.info('[location] start success') } catch { logger.info('[location] start failed') @@ -117,8 +168,12 @@ const onChatClearWatch = async () => { if (locationRefs <= 0) { try { logger.info('[location] end start') - ensureBackgroundTask(ExpoTaskManager) - await ExpoLocation.stopLocationUpdatesAsync(locationTaskName) + if (isIOS) { + stopIOSLocationWatch() + } else { + ensureBackgroundTask(ExpoTaskManager) + await ExpoLocation.stopLocationUpdatesAsync(locationTaskName) + } logger.info('[location] end success') } catch { logger.info('[location] end failed') @@ -463,6 +518,8 @@ const _initNativePlatformListener = () => { initPushListener() + ignorePromise(unregisterLegacyIOSLocationTask()) + const {NetInfo} = _getNative() NetInfo.addEventListener(({type}) => { useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) diff --git a/shared/constants/init/location-throttle.test.ts b/shared/constants/init/location-throttle.test.ts new file mode 100644 index 000000000000..103b4fb6b680 --- /dev/null +++ b/shared/constants/init/location-throttle.test.ts @@ -0,0 +1,197 @@ +/// +import {earthRadiusMeters, shouldRecordFix, type Fix, type FixThrottle} from './location-throttle' + +// north returns c moved due north by meters, which is exact under the spherical distance the +// throttle measures. +const north = (c: Fix, meters: number): Fix => ({...c, lat: c.lat + ((meters / earthRadiusMeters) * 180) / Math.PI}) +const withAccuracy = (c: Fix, accuracy: number): Fix => ({...c, accuracy}) + +type Case = { + name: string + state: 'active' | 'inactive' | 'background' | 'unknown' + last: FixThrottle + next: Fix + record: boolean +} + +const origin: Fix = {accuracy: 10, lat: 37.7749, lon: -122.4194} + +const cases: Array = [ + { + last: {}, + name: 'first fix since the watch started, in the background', + next: origin, + record: true, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'any move in the foreground', + next: north(origin, 1), + record: true, + state: 'active', + }, + { + last: {lastRecorded: origin}, + name: 'short move in the background', + next: north(origin, 10), + record: false, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'short move before the app state is known', + next: north(origin, 10), + record: false, + state: 'unknown', + }, + { + last: {lastRecorded: origin}, + name: 'short move while on screen but not active', + next: north(origin, 10), + record: false, + state: 'inactive', + }, + { + last: {lastRecorded: origin}, + name: 'long move in the background', + next: north(origin, 100), + record: true, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'just past the distance', + next: north(origin, 65.1), + record: true, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'long move with a coarse fix', + next: withAccuracy(north(origin, 100), 100), + record: false, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'move past both accuracies', + next: withAccuracy(north(origin, 111), 100), + record: true, + state: 'background', + }, + { + last: {lastRecorded: withAccuracy(origin, 3000)}, + name: 'coarse fixes, just short of the cap', + next: withAccuracy(north(origin, 199.9), 3000), + record: false, + state: 'background', + }, + { + last: {lastRecorded: withAccuracy(origin, 3000)}, + name: 'coarse fixes, just past the cap', + next: withAccuracy(north(origin, 200.1), 3000), + record: true, + state: 'background', + }, + { + last: {lastRecorded: withAccuracy(origin, 90)}, + name: 'accuracies summing to just under the cap', + next: withAccuracy(north(origin, 189), 100), + record: false, + state: 'background', + }, + { + last: {lastRecorded: withAccuracy(origin, 90)}, + name: 'accuracies summing to just under the cap, moved past them', + next: withAccuracy(north(origin, 190.1), 100), + record: true, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'short move with unknown accuracy', + next: withAccuracy(north(origin, 10), 0), + record: false, + state: 'background', + }, + { + last: {lastRecorded: withAccuracy(origin, 100)}, + name: 'short move with a fix more than twice as sharp', + next: north(origin, 20), + record: true, + state: 'background', + }, + { + last: {lastRecorded: origin}, + name: 'just short of the distance', + next: north(origin, 64.9), + record: false, + state: 'background', + }, +] + +test.each(cases)('$name', ({state, last, next, record}) => { + const res = shouldRecordFix(state, last, next) + expect(res.record).toBe(record) + expect(res.throttle).toEqual(record ? {lastRecorded: next} : last) +}) + +// Feeds fixes to a fresh throttle in the background and returns the indexes of the ones it records. +const recordedAt = (fixes: Array) => { + const recorded: Array = [] + let throttle: FixThrottle = {} + fixes.forEach((fix, i) => { + const res = shouldRecordFix('background', throttle, fix) + throttle = res.throttle + if (res.record) recorded.push(i) + }) + return recorded +} + +test('ignores jitter', () => { + const o = withAccuracy(origin, 100) + const fixes = [o] + for (let i = 0; i < 20; i++) { + fixes.push(north(o, 40), north(o, -40)) + } + expect(recordedAt(fixes)).toEqual([0]) +}) + +test('slow drift', () => { + const fixes: Array = [] + for (let i = 0; i <= 14; i++) { + fixes.push(north(origin, 10 * i)) + } + // 70m from origin at fix 7, then 70m from that at fix 14. + expect(recordedAt(fixes)).toEqual([0, 7, 14]) +}) + +test('ignores jitter around an outlier anchor', () => { + const center = withAccuracy(origin, 0) + const fixes = [withAccuracy(north(center, 40), 100)] + for (let i = 0; i < 20; i++) { + fixes.push(withAccuracy(north(center, -40), 65), withAccuracy(north(center, 40), 100)) + } + expect(recordedAt(fixes)).toEqual([0]) +}) + +test('replaces a coarse anchor', () => { + const center = origin + const coarse = withAccuracy(north(center, 300), 1000) + const fixes = [coarse, center, north(center, 20), north(center, -20), north(center, 70)] + // The locked-on fix replaces the coarse one, jitter around it is ignored, and a real move from + // it is recorded. + expect(recordedAt(fixes)).toEqual([0, 1, 4]) +}) + +test('coarse fixes still record moves', () => { + // Approximate Location reports every fix kilometres wide, so the fixes alone can never tell a + // move from jitter; a steady drive still records. + const o = withAccuracy(origin, 3000) + const fixes: Array = [] + for (let i = 0; i <= 4; i++) { + fixes.push(north(o, 250 * i)) + } + expect(recordedAt(fixes)).toEqual([0, 1, 2, 3, 4]) +}) diff --git a/shared/constants/init/location-throttle.ts b/shared/constants/init/location-throttle.ts new file mode 100644 index 000000000000..716cf47449f7 --- /dev/null +++ b/shared/constants/init/location-throttle.ts @@ -0,0 +1,42 @@ +export type Fix = {accuracy: number; lat: number; lon: number} +// what shouldRecordFix knows of the fixes since the native watch started +export type FixThrottle = {lastRecorded?: Fix} + +const backgroundFixDistance = 65 +const maxBackgroundFixDistance = 200 +export const earthRadiusMeters = 6371008.8 + +const distanceMeters = (a: Fix, b: Fix) => { + const rad = (deg: number) => (deg * Math.PI) / 180 + const dLat = rad(b.lat - a.lat) + const dLon = rad(b.lon - a.lon) + const h = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) * Math.sin(dLon / 2) + return 2 * earthRadiusMeters * Math.asin(Math.min(1, Math.sqrt(h))) +} + +// Out of the foreground a fix is recorded once it lies at least both fixes' accuracies added +// together from the last recorded one: closer than that the two could be the same spot, so jitter +// doesn't count as a move, even when the anchor was itself an outlier. That distance is kept +// between 65 and 200 m, so very coarse fixes (Approximate Location) still record a real move. A fix +// less than half as uncertain as the anchor also records, so a coarse cold fix gets replaced once +// the device locks on; accuracy 0 means unknown and never counts as better. The first fix after the +// watch starts always records, so the move that woke the app gets posted. +export const shouldRecordFix = ( + appState: 'active' | 'inactive' | 'background' | 'unknown', + last: FixThrottle, + next: Fix +): {record: boolean; throttle: FixThrottle} => { + const anchor = last.lastRecorded + let record = !anchor || appState === 'active' + if (!record && anchor) { + const minMove = Math.min( + maxBackgroundFixDistance, + Math.max(backgroundFixDistance, anchor.accuracy + next.accuracy) + ) + record = + distanceMeters(anchor, next) >= minMove || (next.accuracy > 0 && next.accuracy < anchor.accuracy / 2) + } + return {record, throttle: record ? {lastRecorded: next} : last} +} diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts new file mode 100644 index 000000000000..50d35917bf2c --- /dev/null +++ b/shared/constants/init/location-watch.test.ts @@ -0,0 +1,248 @@ +/// +import type * as Init from './index' +import type * as EngineGen from '@/constants/rpc' +import type * as Shell from '@/stores/shell' + +// The init module picks its mobile behavior from the platform globals, so each test loads it +// fresh with them set and the native modules mocked. + +type Fix = {accuracy: number; lat: number; lon: number} + +const calls = new Array() +const fixListeners = new Array<(fix: Fix) => void>() +let startLocationWatchThrows = false +const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} + +const load = (platform: 'ios' | 'android'): typeof Init => { + global.isMobile = true + global.isIOS = platform === 'ios' + global.isAndroid = platform === 'android' + jest.resetModules() + jest.doMock('./platform', () => ({ + getNative: () => ({ + ExpoLocation: { + startLocationUpdatesAsync: async () => { + calls.push('startLocationUpdates') + return Promise.resolve() + }, + stopLocationUpdatesAsync: async () => { + calls.push('stopLocationUpdates') + return Promise.resolve() + }, + }, + ExpoTaskManager: { + defineTask: () => { + calls.push('defineTask') + }, + // Registered until it is unregistered, like the real task store. + isTaskRegisteredAsync: async () => { + calls.push('isTaskRegistered') + return Promise.resolve(!calls.includes('unregisterTask')) + }, + unregisterTaskAsync: async () => { + calls.push('unregisterTask') + return Promise.resolve() + }, + }, + addLocationFixListener: (cb: (fix: Fix) => void) => { + calls.push('addFixListener') + fixListeners.push(cb) + return () => { + calls.push('removeFixListener') + fixListeners.splice(fixListeners.indexOf(cb), 1) + } + }, + requestLocationPermission: async (perm: unknown) => { + calls.push(`requestPermission:${String(perm)}`) + return Promise.resolve() + }, + startLocationWatch: () => { + calls.push('startLocationWatch') + if (startLocationWatchThrows) throw new Error('no native module') + }, + stopLocationWatch: () => { + calls.push('stopLocationWatch') + }, + }), + })) + jest.doMock('@/constants/rpc/rpc-chat-gen', () => ({ + ...jest.requireActual('@/constants/rpc/rpc-chat-gen'), + localLocationUpdateRpcPromise: async ({coord}: {coord: Fix}) => { + calls.push(`locationUpdate:${coord.lat},${coord.lon},${coord.accuracy}`) + return Promise.resolve() + }, + })) + jest.doMock('./shared', () => ({ + _onEngineIncoming: () => {}, + })) + // pulls in the mobile theme, which needs more of react-native than the test mock has + jest.doMock('@/fs/common/lifecycle', () => ({})) + return require('./index') as typeof Init +} + +const watchPosition = () => + ({ + payload: { + params: {convID: new Uint8Array([0xaa, 0xbb]), perm: 1}, + response: { + result: () => { + calls.push('result') + }, + }, + }, + type: 'chat.1.chatUi.chatWatchPosition', + }) as unknown as EngineGen.Actions + +const clearWatch = () => + ({ + payload: {params: {id: 1}, response: {result: () => {}}}, + type: 'chat.1.chatUi.chatClearWatch', + }) as unknown as EngineGen.Actions + +const flush = async () => new Promise(resolve => setTimeout(resolve, 0)) + +const setAppState = (mobileAppState: 'active' | 'background') => { + const {useShellState} = require('@/stores/shell') as typeof Shell + useShellState.setState({mobileAppState}) +} + +const sendFix = (fix: Fix) => fixListeners.forEach(l => l(fix)) + +afterEach(() => { + calls.length = 0 + fixListeners.length = 0 + startLocationWatchThrows = false + jest.dontMock('./platform') + jest.dontMock('@/constants/rpc/rpc-chat-gen') + jest.dontMock('./shared') + jest.dontMock('@/fs/common/lifecycle') + jest.resetModules() + global.isMobile = originalGlobals.isMobile + global.isIOS = originalGlobals.isIOS + global.isAndroid = originalGlobals.isAndroid +}) + +test('iOS starts the native watch once for overlapping watches and stops it after the last clear', async () => { + const init = load('ios') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + expect(calls.filter(c => c === 'stopLocationWatch')).toEqual([]) + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual([ + 'result', + 'requestPermission:1', + 'startLocationWatch', + 'addFixListener', + 'result', + 'requestPermission:1', + 'stopLocationWatch', + 'removeFixListener', + ]) +}) + +test('iOS forwards each native fix to the service in the foreground', async () => { + const init = load('ios') + setAppState('active') + init.onEngineIncoming(watchPosition()) + await flush() + calls.length = 0 + + sendFix({accuracy: 12.7, lat: 37.7749, lon: -122.4194}) + sendFix({accuracy: 5, lat: 37.775, lon: -122.4194}) + await flush() + + expect(calls).toEqual(['locationUpdate:37.7749,-122.4194,12', 'locationUpdate:37.775,-122.4194,5']) +}) + +test('iOS drops background jitter and records a real move', async () => { + const init = load('ios') + setAppState('background') + init.onEngineIncoming(watchPosition()) + await flush() + calls.length = 0 + + sendFix({accuracy: 10, lat: 37.7749, lon: -122.4194}) + // ~11m north + sendFix({accuracy: 10, lat: 37.7750, lon: -122.4194}) + // ~111m north + sendFix({accuracy: 10, lat: 37.7759, lon: -122.4194}) + await flush() + + expect(calls).toEqual(['locationUpdate:37.7749,-122.4194,10', 'locationUpdate:37.7759,-122.4194,10']) +}) + +test('iOS restarts the throttle with each watch, so its first fix always records', async () => { + const init = load('ios') + setAppState('background') + init.onEngineIncoming(watchPosition()) + await flush() + sendFix({accuracy: 10, lat: 37.7749, lon: -122.4194}) + init.onEngineIncoming(clearWatch()) + await flush() + init.onEngineIncoming(watchPosition()) + await flush() + calls.length = 0 + + sendFix({accuracy: 10, lat: 37.775, lon: -122.4194}) + await flush() + + expect(calls).toEqual(['locationUpdate:37.775,-122.4194,10']) +}) + +test('iOS answers the watch before starting the native watch, which may throw', async () => { + startLocationWatchThrows = true + const init = load('ios') + init.onEngineIncoming(watchPosition()) + await flush() + // the failed start gave its ref back, so the next watch tries again + init.onEngineIncoming(watchPosition()) + await flush() + + expect(calls).toEqual([ + 'result', + 'requestPermission:1', + 'startLocationWatch', + 'result', + 'requestPermission:1', + 'startLocationWatch', + ]) +}) + +test('Android asks for permission and runs the expo location task', async () => { + const init = load('android') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual([ + 'result', + 'requestPermission:1', + 'defineTask', + 'startLocationUpdates', + 'stopLocationUpdates', + ]) +}) + +// The second run stands for every launch after the cleanup: unregistering a task that is gone +// throws E_TASK_NOT_FOUND, so it must not be asked for again. +test('iOS removes the legacy expo background location task once', async () => { + const init = load('ios') + await init.unregisterLegacyIOSLocationTask() + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual(['isTaskRegistered', 'unregisterTask', 'isTaskRegistered']) +}) + +test('Android keeps its expo background location task', async () => { + const init = load('android') + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual([]) +}) diff --git a/shared/constants/init/platform-types.ts b/shared/constants/init/platform-types.ts index 8b44511da42a..5fa22e6fa7af 100644 --- a/shared/constants/init/platform-types.ts +++ b/shared/constants/init/platform-types.ts @@ -14,6 +14,8 @@ export type NetInfoModule = { } export type ExpoTaskManagerModule = { defineTask: (taskName: string, cb: (params: {data: unknown; error: unknown}) => Promise) => void + isTaskRegisteredAsync: (taskName: string) => Promise + unregisterTaskAsync: (taskName: string) => Promise } export type DesktopModules = { @@ -33,6 +35,8 @@ export type NativeModules = { ExpoTaskManager: ExpoTaskManagerModule Linking: {getInitialURL: () => Promise} NetInfo: NetInfoModule + // iOS only + addLocationFixListener: (cb: (fix: {accuracy: number; lat: number; lon: number}) => void) => () => void androidAppColorSchemeChanged: (mode: string) => void fsCacheDir: string fsDownloadDir: string @@ -40,6 +44,9 @@ export type NativeModules = { requestLocationPermission: (perm?: unknown) => Promise setupAudioMode: (allowRecord: boolean) => Promise shareListenersRegistered: () => void + // iOS only + startLocationWatch: () => void + stopLocationWatch: () => void } export type NativeSyncModules = { diff --git a/shared/constants/init/platform.native.tsx b/shared/constants/init/platform.native.tsx index e0ef4c703fd8..7af461e3f777 100644 --- a/shared/constants/init/platform.native.tsx +++ b/shared/constants/init/platform.native.tsx @@ -5,11 +5,14 @@ import {Linking} from 'react-native' import {setupAudioMode} from '@/util/audio.native' import {requestLocationPermission} from '@/util/platform-specific' import { + addLocationFixListener, fsCacheDir, fsDownloadDir, androidAppColorSchemeChanged, guiConfig, shareListenersRegistered, + startLocationWatch, + stopLocationWatch, } from 'react-native-kb' import type {DesktopModules, NativeModules, NativeSyncModules} from './platform-types' @@ -22,6 +25,7 @@ export const getNative = (): NativeModules => // it in so consumers can read NetInfo.NetInfoStateType (default-import under // ESM drops named exports that require() used to expose). NetInfo: {...NetInfo, NetInfoStateType}, + addLocationFixListener, androidAppColorSchemeChanged, fsCacheDir, fsDownloadDir, @@ -29,6 +33,8 @@ export const getNative = (): NativeModules => requestLocationPermission, setupAudioMode, shareListenersRegistered, + startLocationWatch, + stopLocationWatch, }) as unknown as NativeModules export const getNativeSync = (): NativeSyncModules => diff --git a/shared/ios/Podfile.lock b/shared/ios/Podfile.lock index 59ced2b3cd54..9f467ed02c14 100644 --- a/shared/ios/Podfile.lock +++ b/shared/ios/Podfile.lock @@ -2966,7 +2966,7 @@ SPEC CHECKSUMS: React-Mapbuffer: e05fcc335c004b3b2a45889244c5c27bbbfad64c React-microtasksnativemodule: 27f8495905481c29ac6acd0e37313b3fbfb17cf0 React-mutationobservernativemodule: 012f3688a7e96d459e5d787264724875939f6f43 - react-native-kb: a221c7cd69de872ef45bd7e6a127cc721f579ee8 + react-native-kb: b8be56e56cb9b28679d99e6eedfcba0b848eb2d5 react-native-keyboard-controller: 3b443a39d5d6f7ad4f836fa51794544a21672851 react-native-netinfo: a05f9b897e76ad24b53f615fed1cbb731932363d react-native-safe-area-context: 678f254e77e69986ae9d9e62721f152e73dbc76b From 493a71a102b86112d0dd83cdaa611783a7b3f58a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 23 Sep 2026 13:42:34 -0400 Subject: [PATCH 2/4] fix(ios): stop orphaned location monitoring at launch, report every fix while active Significant-change monitoring survives termination and keeps relaunching the app, and the service drops an expired share without clearing its watch, so JS init now stops the native watch before the engine can deliver a restored chatWatchPosition. Native stop creates the manager if needed and always stops both update streams when the watch is not wanted. The native distance filter is off while the app is active (every fix, as the expo path reported) and 65 m otherwise, following the lifecycle state native already reports. The clear-watch refcount no longer goes negative, and a restarted watch drops any fix listener a failed stop left behind. --- rnmodules/react-native-kb/ios/Kb.mm | 8 ++++- .../react-native-kb/ios/KbLocationWatcher.h | 12 ++++--- .../react-native-kb/ios/KbLocationWatcher.m | 36 ++++++++++++++----- shared/constants/init/index.tsx | 17 +++++++-- shared/constants/init/location-watch.test.ts | 36 +++++++++++++++++++ 5 files changed, 92 insertions(+), 17 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 3f53901c076e..e634e44420e9 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -906,6 +906,7 @@ + (void)emitAppLifecycle:(NSString *)state { std::lock_guard lock(kbAppLifecycleMutex); kbAppLifecycleState = [state copy]; } + [[Kb locationWatcher] setAppActive:[state isEqualToString:@"active"]]; Kb *instance = kbSharedInstance; if (instance && [instance canEmit]) { [instance emitOnAppLifecycle:@{@"state" : state}]; @@ -923,7 +924,12 @@ + (KbLocationWatcher *)locationWatcher { static KbLocationWatcher *watcher = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - watcher = [[KbLocationWatcher alloc] initWithOnFix:^(CLLocation *location) { + BOOL appActive; + { + std::lock_guard lock(kbAppLifecycleMutex); + appActive = [kbAppLifecycleState isEqualToString:@"active"]; + } + watcher = [[KbLocationWatcher alloc] initWithAppActive:appActive onFix:^(CLLocation *location) { Kb *instance = kbSharedInstance; if (instance && [instance canEmit]) { [instance emitOnLocationFix:@{ diff --git a/rnmodules/react-native-kb/ios/KbLocationWatcher.h b/rnmodules/react-native-kb/ios/KbLocationWatcher.h index d377597d1a2e..5102d0d4aa2f 100644 --- a/rnmodules/react-native-kb/ios/KbLocationWatcher.h +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.h @@ -2,11 +2,15 @@ #import // Runs the OS location service for live location sharing. start and stop may be -// called from any thread; onFix runs on the main thread for every fix with a -// valid accuracy, unthrottled past the distance filter (JS decides which to -// record). +// called from any thread and take effect in call order; onFix runs on the main +// thread for every fix with a valid accuracy past the distance filter (JS +// decides which to record). @interface KbLocationWatcher : NSObject -- (instancetype)initWithOnFix:(void (^)(CLLocation *location))onFix; +- (instancetype)initWithAppActive:(BOOL)appActive onFix:(void (^)(CLLocation *location))onFix; - (void)start; +// Also stops monitoring a previous process left running: significant-change +// monitoring survives termination. - (void)stop; +// Main thread only. Active: every fix; otherwise only moves past 65 m. +- (void)setAppActive:(BOOL)appActive; @end diff --git a/rnmodules/react-native-kb/ios/KbLocationWatcher.m b/rnmodules/react-native-kb/ios/KbLocationWatcher.m index cff337936b4d..d209f166ee0c 100644 --- a/rnmodules/react-native-kb/ios/KbLocationWatcher.m +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.m @@ -1,16 +1,22 @@ #import "KbLocationWatcher.h" +// The JS throttle's floor for fixes outside the foreground. +static const CLLocationDistance kbBackgroundDistanceFilter = 65; + @implementation KbLocationWatcher { - // Everything below is main thread only; the manager delivers its delegate - // callbacks on the run loop of the thread that created it. + // Everything below is main thread only once init returns; the manager + // delivers its delegate callbacks on the run loop of the thread that created + // it. CLLocationManager *_manager; BOOL _wanted; BOOL _running; + BOOL _appActive; void (^_onFix)(CLLocation *); } -- (instancetype)initWithOnFix:(void (^)(CLLocation *))onFix { +- (instancetype)initWithAppActive:(BOOL)appActive onFix:(void (^)(CLLocation *))onFix { self = [super init]; + _appActive = appActive; _onFix = [onFix copy]; return self; } @@ -29,6 +35,17 @@ - (void)stop { }); } +- (void)setAppActive:(BOOL)appActive { + _appActive = appActive; + if (_manager) { + _manager.distanceFilter = [self distanceFilter]; + } +} + +- (CLLocationDistance)distanceFilter { + return _appActive ? kCLDistanceFilterNone : kbBackgroundDistanceFilter; +} + // JS asks for the permission before starting; once the user answers, this // starts watching if it is still wanted. - (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { @@ -50,7 +67,6 @@ - (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError * - (void)apply { if (!_manager) { - if (!_wanted) return; _manager = [CLLocationManager new]; _manager.delegate = self; } @@ -60,21 +76,23 @@ - (void)apply { if (_wanted && !authorized) { NSLog(@"KbLocationWatcher: not watching location: not authorized (status %d)", (int)status); } - if (_wanted && authorized && !_running) { + if (_wanted && authorized) { + if (_running) return; NSLog(@"KbLocationWatcher: starting location updates"); _running = YES; // Needs the `location` UIBackgroundModes entry, or this throws. _manager.allowsBackgroundLocationUpdates = YES; _manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; - // The throttle's floor; JS applies the rest of it. - _manager.distanceFilter = 65; + _manager.distanceFilter = [self distanceFilter]; _manager.activityType = CLActivityTypeOther; _manager.pausesLocationUpdatesAutomatically = YES; _manager.showsBackgroundLocationIndicator = YES; [_manager startUpdatingLocation]; [_manager startMonitoringSignificantLocationChanges]; - } else if (_running && !(_wanted && authorized)) { - NSLog(@"KbLocationWatcher: stopping location updates"); + } else { + if (_running) { + NSLog(@"KbLocationWatcher: stopping location updates"); + } _running = NO; [_manager stopUpdatingLocation]; [_manager stopMonitoringSignificantLocationChanges]; diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 5688ee5e5154..9e9f698b500a 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -103,9 +103,20 @@ const startIOSLocationWatch = () => { const {addLocationFixListener, startLocationWatch} = _getNative() startLocationWatch() fixThrottle = {} + removeFixListener?.() removeFixListener = addLocationFixListener(onLocationFix) } +// Significant-change monitoring outlives the process and keeps relaunching the app, and the service +// drops an expired share without clearing its watch, so each launch stops whatever a previous +// process left running. Runs before the engine delivers any chatWatchPosition, and native applies +// start/stop in call order, so a share the service restores still starts it again. +export const initIOSLocation = () => { + if (!isIOS) return + _getNative().stopLocationWatch() + ignorePromise(unregisterLegacyIOSLocationTask()) +} + const stopIOSLocationWatch = () => { const {stopLocationWatch} = _getNative() stopLocationWatch() @@ -164,8 +175,8 @@ const onChatWatchPosition = async ( const onChatClearWatch = async () => { const {ExpoLocation, ExpoTaskManager} = _getNative() - locationRefs-- - if (locationRefs <= 0) { + locationRefs = Math.max(0, locationRefs - 1) + if (locationRefs === 0) { try { logger.info('[location] end start') if (isIOS) { @@ -518,7 +529,7 @@ const _initNativePlatformListener = () => { initPushListener() - ignorePromise(unregisterLegacyIOSLocationTask()) + initIOSLocation() const {NetInfo} = _getNative() NetInfo.addEventListener(({type}) => { diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts index 50d35917bf2c..beeb04f1d3f3 100644 --- a/shared/constants/init/location-watch.test.ts +++ b/shared/constants/init/location-watch.test.ts @@ -11,6 +11,7 @@ type Fix = {accuracy: number; lat: number; lon: number} const calls = new Array() const fixListeners = new Array<(fix: Fix) => void>() let startLocationWatchThrows = false +let stopLocationWatchThrows = false const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} const load = (platform: 'ios' | 'android'): typeof Init => { @@ -62,6 +63,7 @@ const load = (platform: 'ios' | 'android'): typeof Init => { }, stopLocationWatch: () => { calls.push('stopLocationWatch') + if (stopLocationWatchThrows) throw new Error('no native module') }, }), })) @@ -112,6 +114,7 @@ afterEach(() => { calls.length = 0 fixListeners.length = 0 startLocationWatchThrows = false + stopLocationWatchThrows = false jest.dontMock('./platform') jest.dontMock('@/constants/rpc/rpc-chat-gen') jest.dontMock('./shared') @@ -214,6 +217,39 @@ test('iOS answers the watch before starting the native watch, which may throw', ]) }) +test('iOS init stops a watch a previous process left running, ahead of any watch', async () => { + const init = load('ios') + init.initIOSLocation() + init.onEngineIncoming(watchPosition()) + await flush() + + expect(calls.filter(c => c.endsWith('LocationWatch'))).toEqual(['stopLocationWatch', 'startLocationWatch']) +}) + +test('iOS still starts the native watch after a clear that had no watch', async () => { + const init = load('ios') + init.onEngineIncoming(clearWatch()) + await flush() + init.onEngineIncoming(watchPosition()) + await flush() + + expect(calls.filter(c => c.endsWith('LocationWatch'))).toEqual(['stopLocationWatch', 'startLocationWatch']) +}) + +test('iOS keeps one fix listener when a failed stop left the last one behind', async () => { + const init = load('ios') + init.onEngineIncoming(watchPosition()) + await flush() + stopLocationWatchThrows = true + init.onEngineIncoming(clearWatch()) + await flush() + stopLocationWatchThrows = false + init.onEngineIncoming(watchPosition()) + await flush() + + expect(fixListeners).toHaveLength(1) +}) + test('Android asks for permission and runs the expo location task', async () => { const init = load('android') init.onEngineIncoming(watchPosition()) From 2de265c712bc27967b6ed04daf02b69e5f9f0a5c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 23 Sep 2026 13:47:13 -0400 Subject: [PATCH 3/4] fix(ios): keep app init going when stopping a leftover location watch throws --- shared/constants/init/index.tsx | 6 +++++- shared/constants/init/location-watch.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 9e9f698b500a..c78e3c60e383 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -113,7 +113,11 @@ const startIOSLocationWatch = () => { // start/stop in call order, so a share the service restores still starts it again. export const initIOSLocation = () => { if (!isIOS) return - _getNative().stopLocationWatch() + try { + _getNative().stopLocationWatch() + } catch (error) { + logger.info('[location] failed to stop a leftover location watch: ' + String(error)) + } ignorePromise(unregisterLegacyIOSLocationTask()) } diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts index beeb04f1d3f3..18129bdb55d6 100644 --- a/shared/constants/init/location-watch.test.ts +++ b/shared/constants/init/location-watch.test.ts @@ -226,6 +226,15 @@ test('iOS init stops a watch a previous process left running, ahead of any watch expect(calls.filter(c => c.endsWith('LocationWatch'))).toEqual(['stopLocationWatch', 'startLocationWatch']) }) +test('iOS init survives a failed stop and still removes the legacy task', async () => { + stopLocationWatchThrows = true + const init = load('ios') + expect(() => init.initIOSLocation()).not.toThrow() + await flush() + + expect(calls).toEqual(['stopLocationWatch', 'isTaskRegistered', 'unregisterTask']) +}) + test('iOS still starts the native watch after a clear that had no watch', async () => { const init = load('ios') init.onEngineIncoming(clearWatch()) From dab3a172eaa795ff3cd22ae2f56f6eb9e60f469b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 23 Sep 2026 15:02:44 -0400 Subject: [PATCH 4/4] docs(ios): say that JS init stops the location watch after a reload --- rnmodules/react-native-kb/ios/Kb.mm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index e634e44420e9..f6a8d999d0ca 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -918,8 +918,9 @@ + (void)emitAppLifecycle:(NSString *)state { return kbAppLifecycleState; } -// One per process, so a JS reload neither leaks a second CLLocationManager nor -// stops a running watch. +// One per process, so a JS reload does not leak a second CLLocationManager. The watch +// itself does not survive a reload: JS init stops it, and a share the service restores +// starts it again. + (KbLocationWatcher *)locationWatcher { static KbLocationWatcher *watcher = nil; static dispatch_once_t onceToken;