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 3e4c2fe9caa6..91e352586b31 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 @@ -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) @@ -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 diff --git a/rnmodules/react-native-kb/ios/Kb.h b/rnmodules/react-native-kb/ios/Kb.h index 929434082d22..021354240e3b 100644 --- a/rnmodules/react-native-kb/ios/Kb.h +++ b/rnmodules/react-native-kb/ios/Kb.h @@ -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); diff --git a/rnmodules/react-native-kb/ios/Kb.mm b/rnmodules/react-native-kb/ios/Kb.mm index 48ad75ca8097..ce1963e66423 100644 --- a/rnmodules/react-native-kb/ios/Kb.mm +++ b/rnmodules/react-native-kb/ios/Kb.mm @@ -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 @@ -896,6 +900,22 @@ + (void)setInitialNotification:(NSDictionary *)notification { kbInitialNotification = notification; } ++ (void)emitAppLifecycle:(NSString *)state { + { + std::lock_guard 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 lock(kbAppLifecycleMutex); + return kbAppLifecycleState; +} + + (void)emitPushNotification:(NSDictionary *)notification { Kb *instance = kbSharedInstance; if (instance && [instance canEmit]) { @@ -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; diff --git a/rnmodules/react-native-kb/src/NativeKb.ts b/rnmodules/react-native-kb/src/NativeKb.ts index 86133a5402e1..c0c91004c8e8 100644 --- a/rnmodules/react-native-kb/src/NativeKb.ts +++ b/rnmodules/react-native-kb/src/NativeKb.ts @@ -8,6 +8,8 @@ export interface Spec extends TurboModule { readonly onPushNotification: EventEmitter readonly onPushToken: EventEmitter 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}> getTypedConstants(): { androidIsDeviceSecure: boolean androidIsTestDevice: boolean @@ -68,6 +70,8 @@ export interface Spec extends TurboModule { shareListenersRegistered(): void setEnablePasteImage(enabled: boolean): void clearLocalLogs(): Promise + // the last state onAppLifecycle carried, including one sent before JS listened + getAppLifecycleState(): string } export default TurboModuleRegistry.getEnforcing('Kb') diff --git a/rnmodules/react-native-kb/src/index.tsx b/rnmodules/react-native-kb/src/index.tsx index aeccfb245834..4d7fcd36104a 100644 --- a/rnmodules/react-native-kb/src/index.tsx +++ b/rnmodules/react-native-kb/src/index.tsx @@ -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() } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleForwarder.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleForwarder.kt new file mode 100644 index 000000000000..cc69eaad34fb --- /dev/null +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/AppLifecycleForwarder.kt @@ -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") + } +} diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index f4369a931039..f17293a9bf95 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -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 { @@ -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 diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt index 65de3e828c1c..fa1d20abc963 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainApplication.kt @@ -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) { diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index dfc15a3d789a..bd9088c2c4f3 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -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' @@ -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') @@ -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 diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts new file mode 100644 index 000000000000..a1a49a689b3b --- /dev/null +++ b/shared/constants/init/app-state.test.ts @@ -0,0 +1,111 @@ +/// +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('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(['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: ', () => { + 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') + }) +}) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 88b76432ee8e..d274658dd549 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -358,9 +358,14 @@ const _initNativePlatformListener = () => { appFocused = false } - // Native KeybaseSetAppState* is the only writer of Go MobileAppState. + // Native reports the app state to Go and to JS from the same callbacks; JS only mirrors it. logger.info(`app focus changed: ${s.mobileAppState}`) s.dispatch.changedFocus(appFocused) + + if (s.mobileAppState === 'active') { + // only reload on foreground + useSettingsContactsState.getState().dispatch.loadContactPermissions() + } }) const configureAndroidCacheDir = () => { @@ -412,14 +417,6 @@ const _initNativePlatformListener = () => { ignorePromise(f()) }) - useShellState.subscribe((s, old) => { - if (s.mobileAppState === old.mobileAppState) return - if (s.mobileAppState === 'active') { - // only reload on foreground - useSettingsContactsState.getState().dispatch.loadContactPermissions() - } - }) - if (isAndroid) { useDarkModeState.subscribe((s, old) => { if (s.darkModePreference === old.darkModePreference) return diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index c0266f11ad80..a338bd9d773b 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -49,6 +49,7 @@ import {syncInboxBadgeState} from '@/chat/inbox/badge-state' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' +import {addAppLifecycleListener, getAppLifecycleState, type AppLifecycleState} from 'react-native-kb' const _sharedUnsubs: Array<() => void> = __DEV__ ? (globalThis.__hmr_sharedUnsubs ??= []) : [] @@ -237,6 +238,36 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => } } +// Native reports the app state from the same callbacks that report it to Go, and this is the only +// writer of mobileAppState. Desktop has no lifecycle; its window focus goes straight to appFocused. +export const applyMobileAppState = (state: AppLifecycleState) => { + if (!isMobile) return + useShellState.getState().dispatch.setMobileAppState(state) +} + +const isMobileAppState = (s: string): s is AppLifecycleState => + s === 'active' || s === 'inactive' || s === 'background' + +const onNativeAppLifecycle = (state: string) => { + logger.info(`[AppState] native: ${state}`) + if (isMobileAppState(state)) { + applyMobileAppState(state) + } else { + logger.warn(`[AppState] unmapped state ${state}, leaving the app state as it was`) + } +} + +// Subscribe before seeding: events emitted while no listener existed are only in the seed, and one +// emitted after the seed read reaches the listener later on this thread. +export const listenForAppLifecycle = (): (() => void) => { + const stop = addAppLifecycleListener(onNativeAppLifecycle) + const initial: string = getAppLifecycleState() + if (isMobileAppState(initial)) { + applyMobileAppState(initial) + } + return stop +} + const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { const next = nextNavState as Util.NavState const prev = previousNavState as Util.NavState diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 04648fc15bdd..140b2c310f46 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -206,10 +206,18 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi let state = application.applicationState log.info("notifyAppState: notifying service with new appState: \(state.rawValue)") switch state { - case .active: Keybasego.KeybaseSetAppStateForeground() - case .background: Keybasego.KeybaseSetAppStateBackground() - case .inactive: Keybasego.KeybaseSetAppStateInactive() - default: Keybasego.KeybaseSetAppStateForeground() + case .background: + Keybasego.KeybaseSetAppStateBackground() + KbEmitAppLifecycle("background") + case .inactive: + // INACTIVE disconnects gregor (#29664) and stops the kbhttp server + // (#29665): chat drops and images fail to load until the next FOREGROUND + // or BACKGROUNDACTIVE. Keep reporting the true state until Go is fixed. + Keybasego.KeybaseSetAppStateInactive() + KbEmitAppLifecycle("inactive") + default: + Keybasego.KeybaseSetAppStateForeground() + KbEmitAppLifecycle("active") } } @@ -244,6 +252,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi dim = screenBounds.height } let square = CGRect(origin: screenBounds.origin, size: CGSize(width: dim, height: dim)) + self.resignImageView?.removeFromSuperview() self.resignImageView = UIImageView(frame: square) self.resignImageView?.contentMode = .center self.resignImageView?.alpha = 0 @@ -252,6 +261,14 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi if let view = self.resignImageView { window.addSubview(view) } } + // Called by SceneDelegate when the scene goes away; didStartReactNative + // rebuilds both if a new scene connects. + func didDisconnectScene() { + self.window = nil + self.resignImageView?.removeFromSuperview() + self.resignImageView = nil + } + func addDrop(_ rootView: UIView) { let dropInteraction = UIDropInteraction(delegate: self) dropInteraction.allowsSimultaneousDropSessions = true @@ -385,6 +402,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi override func applicationWillTerminate(_ application: UIApplication) { self.window?.rootViewController?.view.isHidden = true Keybasego.KeybaseAppWillExit(PushNotifier()) + KbEmitAppLifecycle("background") } func hideCover() { @@ -403,7 +421,11 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi } completion: { finished in log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)") } + // INACTIVE disconnects gregor (#29664) and stops the kbhttp server + // (#29665): chat drops and images fail to load until the next FOREGROUND + // or BACKGROUNDACTIVE. Keep reporting the true state until Go is fixed. Keybasego.KeybaseSetAppStateInactive() + KbEmitAppLifecycle("inactive") } override func applicationDidEnterBackground(_ application: UIApplication) { @@ -417,6 +439,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("applicationDidEnterBackground: notifying go.") let requestTime = Keybasego.KeybaseAppDidEnterBackground() log.info("applicationDidEnterBackground: after notifying go.") + KbEmitAppLifecycle("background") if requestTime && (self.shutdownTask == UIBackgroundTaskIdentifier.invalid) { self.shutdownTask = UIApplication.shared.beginBackgroundTask { @@ -451,6 +474,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // Forwarded from sceneDidBecomeActive, where applicationState still reads // .inactive; notifyAppState would stop the http server. Keybasego.KeybaseSetAppStateForeground() + KbEmitAppLifecycle("active") // Re-emit a notification the user tapped while React Native wasn't ready yet. KbEmitStoredNotificationOnBecomeActive() @@ -465,6 +489,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi // claiming the user is on-screen — FOREGROUND waits for didBecomeActive. // Can't use notifyAppState here: applicationState is still .background. Keybasego.KeybaseSetAppStateBackgroundActive() + KbEmitAppLifecycle("inactive") NSLog("applicationWillEnterForeground: done") } diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift index 17be345c2006..7902b04e1dbf 100644 --- a/shared/ios/Keybase/SceneDelegate.swift +++ b/shared/ios/Keybase/SceneDelegate.swift @@ -15,4 +15,9 @@ class SceneDelegate: ExpoAppSceneDelegate { else { return } appDelegate.didStartReactNative(in: window) } + + override func sceneDidDisconnect(_ scene: UIScene) { + super.sceneDidDisconnect(scene) + (UIApplication.shared.delegate as? AppDelegate)?.didDisconnectScene() + } }