Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions rnmodules/react-native-kb/ios/Kb.mm
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import "Kb.h"
#import "KbLocationWatcher.h"
#import "Keybasego.h"
#import "engine-reset-backoff.h"
#import <Foundation/Foundation.h>
Expand Down Expand Up @@ -905,6 +906,7 @@ + (void)emitAppLifecycle:(NSString *)state {
std::lock_guard<std::mutex> lock(kbAppLifecycleMutex);
kbAppLifecycleState = [state copy];
}
[[Kb locationWatcher] setAppActive:[state isEqualToString:@"active"]];
Kb *instance = kbSharedInstance;
if (instance && [instance canEmit]) {
[instance emitOnAppLifecycle:@{@"state" : state}];
Expand All @@ -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<std::mutex> 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]) {
Expand Down
16 changes: 16 additions & 0 deletions rnmodules/react-native-kb/ios/KbLocationWatcher.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#import <CoreLocation/CoreLocation.h>
#import <Foundation/Foundation.h>

// 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 <CLLocationManagerDelegate>
- (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
102 changes: 102 additions & 0 deletions rnmodules/react-native-kb/ios/KbLocationWatcher.m
Original file line number Diff line number Diff line change
@@ -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<CLLocation *> *)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
1 change: 1 addition & 0 deletions rnmodules/react-native-kb/react-native-kb.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions rnmodules/react-native-kb/src/NativeKb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface Spec extends TurboModule {
readonly onShareData: EventEmitter<{text?: string; localPaths?: Array<string>}>
// '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
Expand Down Expand Up @@ -72,6 +74,9 @@ export interface Spec extends TurboModule {
clearLocalLogs(): Promise<void>
// 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<Spec>('Kb')
20 changes: 20 additions & 0 deletions rnmodules/react-native-kb/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
92 changes: 82 additions & 10 deletions shared/constants/init/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -463,6 +533,8 @@ const _initNativePlatformListener = () => {

initPushListener()

initIOSLocation()

const {NetInfo} = _getNative()
NetInfo.addEventListener(({type}) => {
useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type)
Expand Down
Loading