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..f6a8d999d0ca 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 @@ -905,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}]; @@ -916,6 +918,40 @@ + (void)emitAppLifecycle:(NSString *)state { return kbAppLifecycleState; } +// 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; + dispatch_once(&onceToken, ^{ + 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:@{ + @"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..5102d0d4aa2f --- /dev/null +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.h @@ -0,0 +1,16 @@ +#import +#import + +// Runs the OS location service for live location sharing. start and stop may be +// 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)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 new file mode 100644 index 000000000000..d209f166ee0c --- /dev/null +++ b/rnmodules/react-native-kb/ios/KbLocationWatcher.m @@ -0,0 +1,102 @@ +#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 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)initWithAppActive:(BOOL)appActive onFix:(void (^)(CLLocation *))onFix { + self = [super init]; + _appActive = appActive; + _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]; + }); +} + +- (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 { + [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) { + _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) { + if (_running) return; + NSLog(@"KbLocationWatcher: starting location updates"); + _running = YES; + // Needs the `location` UIBackgroundModes entry, or this throws. + _manager.allowsBackgroundLocationUpdates = YES; + _manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + _manager.distanceFilter = [self distanceFilter]; + _manager.activityType = CLActivityTypeOther; + _manager.pausesLocationUpdatesAutomatically = YES; + _manager.showsBackgroundLocationIndicator = YES; + [_manager startUpdatingLocation]; + [_manager startMonitoringSignificantLocationChanges]; + } else { + if (_running) { + 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..c78e3c60e383 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,67 @@ 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?.() + 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 + try { + _getNative().stopLocationWatch() + } catch (error) { + logger.info('[location] failed to stop a leftover location watch: ' + String(error)) + } + ignorePromise(unregisterLegacyIOSLocationTask()) +} + +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 +159,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') @@ -113,12 +179,16 @@ 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') - 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 +533,8 @@ const _initNativePlatformListener = () => { initPushListener() + initIOSLocation() + 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..18129bdb55d6 --- /dev/null +++ b/shared/constants/init/location-watch.test.ts @@ -0,0 +1,293 @@ +/// +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 +let stopLocationWatchThrows = 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') + if (stopLocationWatchThrows) throw new Error('no native module') + }, + }), + })) + 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 + stopLocationWatchThrows = 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('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 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()) + 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()) + 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