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 @@ -61,6 +61,21 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
// the generated emit helpers would NPE before then.
private fun canEmit(): Boolean = mEventEmitterCallback != null

@ReactMethod(isBlockingSynchronousMethod = true)
override fun getAppLifecycleState(): String = appLifecycleState

private fun emitAppLifecycleInternal(state: String) {
if (reactContext.hasActiveReactInstance() && canEmit()) {
try {
val payload = Arguments.createMap()
payload.putString("state", state)
emitOnAppLifecycle(payload)
} catch (e: Exception) {
NativeLogger.error("emitAppLifecycleInternal failed to emit: " + e.message)
}
}
}

@ReactMethod
override fun clearLocalLogs(promise: Promise) {
promise.resolve(null)
Expand Down Expand Up @@ -809,6 +824,20 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
module.emitPushNotificationInternal(notification)
}

// Written on the main thread by the process lifecycle observer, read on
// the JS thread by getAppLifecycleState.
@Volatile
private var appLifecycleState: String = "background"

// Main thread only. Call next to each Go SetAppState* report with
// "active", "inactive" or "background"; the latest value is kept so JS
// can read what it missed before it listened.
@JvmStatic
fun emitAppLifecycle(state: String) {
appLifecycleState = state
instance?.emitAppLifecycleInternal(state)
}

@JvmStatic
fun emitShareData(data: WritableMap) {
val module = instance
Expand Down
4 changes: 4 additions & 0 deletions rnmodules/react-native-kb/ios/Kb.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
FOUNDATION_EXPORT void KbSetDeviceToken(NSString *token);
FOUNDATION_EXPORT void KbSetInitialNotification(NSDictionary *notification);
FOUNDATION_EXPORT void KbEmitPushNotification(NSDictionary *notification);
// Main thread only. Call next to each Go SetAppState* report with "active",
// "inactive" or "background"; the latest value is kept for getAppLifecycleState
// so JS can read what it missed before it listened.
FOUNDATION_EXPORT void KbEmitAppLifecycle(NSString *state);
// Re-emits a stored user-interaction notification once when the app becomes
// active (covers notification taps that arrive before React Native is ready).
FOUNDATION_EXPORT void KbEmitStoredNotificationOnBecomeActive(void);
24 changes: 24 additions & 0 deletions rnmodules/react-native-kb/ios/Kb.mm
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ + (id)sharedFsPathsHolder {
static BOOL kbPasteImageEnabled = NO;
static NSString *kbStoredDeviceToken = nil;
static NSDictionary *kbInitialNotification = nil;
// Written on the main thread by the app delegate, read on the JS thread by
// getAppLifecycleState.
static std::mutex kbAppLifecycleMutex;
static NSString *kbAppLifecycleState = @"background";

// The bridge is created on the JS thread and consumed by the reader thread,
// so every access goes through this lock — a plain shared_ptr member would be
Expand Down Expand Up @@ -896,6 +900,22 @@ + (void)setInitialNotification:(NSDictionary *)notification {
kbInitialNotification = notification;
}

+ (void)emitAppLifecycle:(NSString *)state {
{
std::lock_guard<std::mutex> lock(kbAppLifecycleMutex);
kbAppLifecycleState = [state copy];
}
Kb *instance = kbSharedInstance;
if (instance && [instance canEmit]) {
[instance emitOnAppLifecycle:@{@"state" : state}];
}
}

RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getAppLifecycleState) {
std::lock_guard<std::mutex> lock(kbAppLifecycleMutex);
return kbAppLifecycleState;
}

+ (void)emitPushNotification:(NSDictionary *)notification {
Kb *instance = kbSharedInstance;
if (instance && [instance canEmit]) {
Expand Down Expand Up @@ -960,6 +980,10 @@ void KbEmitPushNotification(NSDictionary *notification) {
[Kb emitPushNotification:notification];
}

void KbEmitAppLifecycle(NSString *state) {
[Kb emitAppLifecycle:state];
}

void KbEmitStoredNotificationOnBecomeActive(void) {
NSDictionary *stored = kbInitialNotification;
kbInitialNotification = nil;
Expand Down
4 changes: 4 additions & 0 deletions rnmodules/react-native-kb/src/NativeKb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface Spec extends TurboModule {
readonly onPushNotification: EventEmitter<UnsafeObject>
readonly onPushToken: EventEmitter<string>
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}>
getTypedConstants(): {
androidIsDeviceSecure: boolean
androidIsTestDevice: boolean
Expand Down Expand Up @@ -68,6 +70,8 @@ export interface Spec extends TurboModule {
shareListenersRegistered(): void
setEnablePasteImage(enabled: boolean): void
clearLocalLogs(): Promise<void>
// the last state onAppLifecycle carried, including one sent before JS listened
getAppLifecycleState(): string
}

export default TurboModuleRegistry.getEnforcing<Spec>('Kb')
11 changes: 11 additions & 0 deletions rnmodules/react-native-kb/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ export const onShareData = (
return Kb.onShareData(callback)
}

export type AppLifecycleState = 'active' | 'inactive' | 'background'

export const addAppLifecycleListener = (callback: (state: AppLifecycleState) => void): (() => void) => {
const sub = Kb.onAppLifecycle(e => callback(e.state as AppLifecycleState))
return () => sub.remove()
}

export const getAppLifecycleState = (): AppLifecycleState => {
return Kb.getAppLifecycleState() as AppLifecycleState
}

export const engineReset = (): void => {
return Kb.engineReset()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.keybase.ossifrage

import android.content.Context
import android.os.Bundle
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.reactnativekb.KbModule
import io.keybase.ossifrage.modules.NativeLogger
import keybase.Keybase

// Reports the whole process's visibility, not one activity's, to Go and JS
// together, so both see the same state. Process ON_STOP only fires once no
// activity is started, so moving between our own activities never looks like a
// trip to the background.
internal class AppLifecycleForwarder(private val context: Context) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = foreground("onStart")

override fun onResume(owner: LifecycleOwner) = foreground("onResume")

override fun onStop(owner: LifecycleOwner) {
NativeLogger.info("AppLifecycleForwarder: process onStop")
// appDidEnterBackground already reports BACKGROUND (and flushes) when it
// returns false; calling setAppStateBackground too would flush twice.
if (Keybase.appDidEnterBackground()) {
Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle()))
}
KbModule.emitAppLifecycle("background")
}

private fun foreground(event: String) {
NativeLogger.info("AppLifecycleForwarder: process $event")
Keybase.setAppStateForeground()
KbModule.emitAppLifecycle("active")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,6 @@ class MainActivity : ReactActivity() {
override fun onPause() {
NativeLogger.info("Activity onPause")
super.onPause()
if (Keybase.appDidEnterBackground()) {
Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(this, Bundle()))
} else {
Keybase.setAppStateBackground()
}
}

private fun getFileNameFromResolver(resolver: ContentResolver, uri: Uri, extension: String?): String {
Expand Down Expand Up @@ -158,19 +153,24 @@ class MainActivity : ReactActivity() {
NativeLogger.info("Activity onResume")
super.onResume()
Keybase.setAppStateForeground()
KbModule.emitAppLifecycle("active")
handleIntent()
}

override fun onStart() {
NativeLogger.info("Activity onStart")
super.onStart()
Keybase.setAppStateForeground()
}

override fun onDestroy() {
NativeLogger.info("Activity onDestroy")
super.onDestroy()
Keybase.appWillExit(KBPushNotifier(this, Bundle()))
// A configuration change destroys and recreates the activity; only a
// real finish is the app going away.
if (isFinishing) {
Keybase.appWillExit(KBPushNotifier(this, Bundle()))
KbModule.emitAppLifecycle("background")
}
}

private var cachedIntent: Intent? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
NativeLogger.info("MainApplication created")
super.onCreate()
// Before any activity starts, so the first process ON_START is seen.
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleForwarder(this))
try {
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
Expand Down
26 changes: 13 additions & 13 deletions shared/app/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as React from 'react'
import Main from './main'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated'
import {AppRegistry, AppState, Appearance, Platform} from 'react-native'
import {AppRegistry, Appearance, Platform} from 'react-native'
import {PortalProvider} from '@/common-adapters/portal.native'
import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context'
import {makeEngine} from '../engine'
Expand All @@ -19,6 +19,7 @@ import {darkModeSupported, guiConfig} from 'react-native-kb'
import * as DarkMode from '@/stores/darkmode'
import {colors, darkColors} from '@/styles/colors'
import {initPlatformListener, onEngineConnected, onEngineDisconnected, onEngineIncoming} from '@/constants/init/index'
import {listenForAppLifecycle} from '@/constants/init/shared'
import logger from '@/logger'

logger.info('INIT App index module load')
Expand Down Expand Up @@ -57,38 +58,37 @@ const initDarkMode = () => {
}

const useDarkHookup = () => {
const appStateRef = React.useRef('active')
const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode)
const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState)

React.useEffect(() => {
const appStateChangeSub = AppState.addEventListener('change', nextAppState => {
appStateRef.current = nextAppState
if (nextAppState !== 'unknown' && nextAppState !== 'extension') {
setMobileAppState(nextAppState)
}

if (nextAppState === 'active') {
const stopWatchingAppState = useShellState.subscribe((s, old) => {
if (s.mobileAppState === old.mobileAppState) return
if (s.mobileAppState === 'active') {
setSystemDarkMode(Appearance.getColorScheme() === 'dark')
}
})

// only watch dark changes if in foreground due to ios calling this to take snapshots
const darkSub = Appearance.addChangeListener(() => {
if (appStateRef.current === 'active') {
if (useShellState.getState().mobileAppState === 'active') {
setSystemDarkMode(Appearance.getColorScheme() === 'dark')
}
})

return () => {
appStateChangeSub.remove()
stopWatchingAppState()
darkSub.remove()
}
}, [setSystemDarkMode, setMobileAppState])
}, [setSystemDarkMode])
}

const useAppLifecycle = () => {
React.useEffect(() => listenForAppLifecycle(), [])
}

const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => {
const {children} = p
useAppLifecycle()
useDarkHookup()

return children
Expand Down
111 changes: 111 additions & 0 deletions shared/constants/init/app-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/// <reference types="jest" />
import {resetAllStores} from '@/util/zustand'
import {useShellState} from '@/stores/shell'
import logger from '@/logger'
import {applyMobileAppState, listenForAppLifecycle} from './shared'

type Lifecycle = 'active' | 'inactive' | 'background'

const mockNative: {listeners: Array<(state: string) => void>; state: string} = {listeners: [], state: 'active'}
const mockRNAppStateListeners: Array<(state: string) => void> = []

jest.mock('react-native-kb', () => ({
addAppLifecycleListener: (cb: (state: string) => void) => {
mockNative.listeners.push(cb)
return () => {
mockNative.listeners = mockNative.listeners.filter(l => l !== cb)
}
},
getAppLifecycleState: () => mockNative.state,
}))

jest.mock('react-native', () => ({
...jest.requireActual<object>('react-native'),
AppState: {
addEventListener: (_type: string, cb: (state: string) => void) => {
mockRNAppStateListeners.push(cb)
return {remove: () => {}}
},
currentState: 'active',
},
}))

const g = globalThis as unknown as {isMobile: boolean}
const nativeSays = (state: string) => mockNative.listeners.forEach(l => l(state))
let stopListening: (() => void) | undefined

beforeEach(() => {
g.isMobile = true
resetAllStores()
// the shell store keeps its state across an account-level reset on purpose
useShellState.setState({mobileAppState: 'unknown'})
mockNative.listeners = []
mockNative.state = 'active'
mockRNAppStateListeners.length = 0
jest.spyOn(logger, 'info').mockImplementation(() => {})
jest.spyOn(logger, 'warn').mockImplementation(() => {})
})

afterEach(() => {
stopListening?.()
stopListening = undefined
g.isMobile = false
jest.restoreAllMocks()
})

describe('the app state native reports', () => {
test.each<Lifecycle>(['active', 'inactive', 'background'])('%s arrives through the native event', state => {
mockNative.state = 'background'
stopListening = listenForAppLifecycle()
useShellState.setState({mobileAppState: 'unknown'})
nativeSays(state)
expect(useShellState.getState().mobileAppState).toBe(state)
})

test('is seeded from the state native already holds, since earlier events went to no listener', () => {
mockNative.state = 'inactive'
stopListening = listenForAppLifecycle()
expect(useShellState.getState().mobileAppState).toBe('inactive')
})

test('is applied in arrival order', () => {
stopListening = listenForAppLifecycle()
nativeSays('background')
nativeSays('active')
expect(useShellState.getState().mobileAppState).toBe('active')
})

test('each event is logged with logger.info as [AppState] native: <state>', () => {
const log = jest.spyOn(logger, 'info').mockImplementation(() => {})
stopListening = listenForAppLifecycle()
nativeSays('background')
expect(log).toHaveBeenCalledWith('[AppState] native: background')
})

test('a state we do not map leaves the app state alone rather than guessing', () => {
stopListening = listenForAppLifecycle()
nativeSays('background')
nativeSays('extension')
expect(useShellState.getState().mobileAppState).toBe('background')
})

test('RN AppState change events are ignored', () => {
stopListening = listenForAppLifecycle()
nativeSays('active')
mockRNAppStateListeners.forEach(l => l('background'))
expect(useShellState.getState().mobileAppState).toBe('active')
})

test('stops applying events once unsubscribed', () => {
const stop = listenForAppLifecycle()
stop()
nativeSays('background')
expect(useShellState.getState().mobileAppState).toBe('active')
})

test('desktop has no lifecycle to learn', () => {
g.isMobile = false
applyMobileAppState('active')
expect(useShellState.getState().mobileAppState).toBe('unknown')
})
})
Loading