feat: mobile web bridge (#1597)

This commit is contained in:
Mo
2022-09-19 14:47:15 -05:00
committed by GitHub
parent f80cc5b822
commit c4d7761496
28 changed files with 462 additions and 104 deletions

View File

@@ -1,10 +1,12 @@
import { IsMobileWeb } from '@Lib/Utils'
import { MobileWebApp } from '@Root/MobileWebApp'
import { SNLog } from '@standardnotes/snjs' import { SNLog } from '@standardnotes/snjs'
import { AppRegistry } from 'react-native' import { AppRegistry } from 'react-native'
import 'react-native-gesture-handler' import 'react-native-gesture-handler'
import { enableScreens } from 'react-native-screens' import { enableScreens } from 'react-native-screens'
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
import { name as appName } from './app.json' import { name as appName } from './app.json'
import { App } from './src/App' import { NativeApp } from './src/NativeApp'
import { enableAndroidFontFix } from './src/Style/android_text_fix' import { enableAndroidFontFix } from './src/Style/android_text_fix'
enableScreens() enableScreens()
@@ -28,11 +30,11 @@ console.warn = function filterWarnings(msg) {
"[react-native-gesture-handler] Seems like you're using an old API with gesture components", "[react-native-gesture-handler] Seems like you're using an old API with gesture components",
] ]
if (!supressedWarnings.some(entry => msg.includes(entry))) { if (!supressedWarnings.some((entry) => msg.includes(entry))) {
originalWarn.apply(console, arguments) originalWarn.apply(console, arguments)
} }
} }
enableAndroidFontFix() enableAndroidFontFix()
AppRegistry.registerComponent(appName, () => App) AppRegistry.registerComponent(appName, () => (IsMobileWeb ? MobileWebApp : NativeApp))

View File

@@ -2,7 +2,7 @@ import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/Appl
import { AlwaysOpenWebAppOnLaunchKey } from '@Lib/constants' import { AlwaysOpenWebAppOnLaunchKey } from '@Lib/constants'
import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks' import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks'
import { ScreenStatus } from '@Lib/StatusManager' import { ScreenStatus } from '@Lib/StatusManager'
import { IsDev } from '@Lib/Utils' import { IsMobileWeb } from '@Lib/Utils'
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native' import { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack' import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView' import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
@@ -22,10 +22,10 @@ import { Dimensions, Keyboard, ScaledSize, StatusBar } from 'react-native'
import DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout' import DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout'
import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App'
import { ApplicationContext } from './ApplicationContext' import { ApplicationContext } from './ApplicationContext'
import { MobileWebAppContainer } from './MobileWebAppContainer' import { MobileWebAppContainer } from './MobileWebAppContainer'
import { ModalStackNavigationProp } from './ModalStack' import { ModalStackNavigationProp } from './ModalStack'
import { HeaderTitleParams } from './NativeApp'
export type AppStackNavigatorParamList = { export type AppStackNavigatorParamList = {
[SCREEN_NOTES]: HeaderTitleParams [SCREEN_NOTES]: HeaderTitleParams
@@ -121,7 +121,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
[application], [application],
) )
if (IsDev) { if (IsMobileWeb) {
return ( return (
<AppStack.Navigator <AppStack.Navigator
screenOptions={() => ({ screenOptions={() => ({

View File

@@ -0,0 +1,62 @@
import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs'
import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native'
export class AppStateObserverService extends AbstractService<ReactNativeToWebEvent> {
private mostRecentState?: ReactNativeToWebEvent
private removeListener: NativeEventSubscription
private ignoringStateChanges = false
constructor() {
const bus = new InternalEventBus()
super(bus)
this.removeListener = AppState.addEventListener('change', async (nextAppState: AppStateStatus) => {
if (this.ignoringStateChanges) {
return
}
// if the most recent state is not 'background' ('inactive'), then we're going
// from inactive to active, which doesn't really happen unless you, say, swipe
// notification center in iOS down then back up. We don't want to lock on this state change.
const isResuming = nextAppState === 'active'
const isResumingFromBackground = isResuming && this.mostRecentState === ReactNativeToWebEvent.EnteringBackground
const isEnteringBackground = nextAppState === 'background'
const isLosingFocus = nextAppState === 'inactive'
if (isEnteringBackground) {
this.notifyStateChange(ReactNativeToWebEvent.EnteringBackground)
}
if (isResumingFromBackground || isResuming) {
if (isResumingFromBackground) {
this.notifyStateChange(ReactNativeToWebEvent.ResumingFromBackground)
}
// Notify of GainingFocus even if resuming from background
this.notifyStateChange(ReactNativeToWebEvent.GainingFocus)
return
}
if (isLosingFocus) {
this.notifyStateChange(ReactNativeToWebEvent.LosingFocus)
}
})
}
beginIgnoringStateChanges() {
this.ignoringStateChanges = true
}
stopIgnoringStateChanges() {
this.ignoringStateChanges = false
}
deinit() {
this.removeListener.remove()
}
private notifyStateChange(state: ReactNativeToWebEvent): void {
this.mostRecentState = state
void this.notifyEvent(state)
}
}

View File

@@ -12,7 +12,7 @@ import React, { useContext } from 'react'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App' import { HeaderTitleParams } from './NativeApp'
type HistoryStackNavigatorParamList = { type HistoryStackNavigatorParamList = {
[SCREEN_NOTE_HISTORY]: (HeaderTitleParams & { noteUuid: string }) | (undefined & { noteUuid: string }) [SCREEN_NOTE_HISTORY]: (HeaderTitleParams & { noteUuid: string }) | (undefined & { noteUuid: string })

View File

@@ -23,13 +23,13 @@ import { BackupsService } from './BackupsService'
import { ComponentManager } from './ComponentManager' import { ComponentManager } from './ComponentManager'
import { FilesService } from './FilesService' import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService' import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface' import { MobileDevice } from './Interface'
import { push } from './NavigationService' import { push } from './NavigationService'
import { PreferencesManager } from './PreferencesManager' import { PreferencesManager } from './PreferencesManager'
import { SNReactNativeCrypto } from './ReactNativeCrypto' import { SNReactNativeCrypto } from './ReactNativeCrypto'
import { ReviewService } from './ReviewService' import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager' import { StatusManager } from './StatusManager'
import { IsDev } from './Utils' import { IsDev, IsMobileWeb } from './Utils'
type MobileServices = { type MobileServices = {
applicationState: ApplicationState applicationState: ApplicationState
@@ -52,7 +52,7 @@ export class MobileApplication extends SNApplication {
static previouslyLaunched = false static previouslyLaunched = false
constructor(deviceInterface: MobileDeviceInterface, identifier: string) { constructor(deviceInterface: MobileDevice, identifier: string) {
super({ super({
environment: Environment.Mobile, environment: Environment.Mobile,
platform: platformFromString(Platform.OS), platform: platformFromString(Platform.OS),
@@ -135,7 +135,7 @@ export class MobileApplication extends SNApplication {
} }
promptForChallenge(challenge: Challenge) { promptForChallenge(challenge: Challenge) {
if (IsDev) { if (IsMobileWeb) {
return return
} }

View File

@@ -4,14 +4,14 @@ import { ApplicationState } from './ApplicationState'
import { BackupsService } from './BackupsService' import { BackupsService } from './BackupsService'
import { FilesService } from './FilesService' import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService' import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface' import { MobileDevice } from './Interface'
import { PreferencesManager } from './PreferencesManager' import { PreferencesManager } from './PreferencesManager'
import { ReviewService } from './ReviewService' import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager' import { StatusManager } from './StatusManager'
export class ApplicationGroup extends SNApplicationGroup { export class ApplicationGroup extends SNApplicationGroup {
constructor() { constructor() {
super(new MobileDeviceInterface()) super(new MobileDevice())
} }
override async initialize(_callback?: any): Promise<void> { override async initialize(_callback?: any): Promise<void> {
@@ -21,7 +21,7 @@ export class ApplicationGroup extends SNApplicationGroup {
} }
private createApplication = async (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => { private createApplication = async (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => {
const application = new MobileApplication(deviceInterface as MobileDeviceInterface, descriptor.identifier) const application = new MobileApplication(deviceInterface as MobileDevice, descriptor.identifier)
const internalEventBus = new InternalEventBus() const internalEventBus = new InternalEventBus()
const applicationState = new ApplicationState(application) const applicationState = new ApplicationState(application)
const reviewService = new ReviewService(application, internalEventBus) const reviewService = new ReviewService(application, internalEventBus)

View File

@@ -1,4 +1,4 @@
import { MobileDeviceInterface } from '@Lib/Interface' import { MobileDevice } from '@Lib/Interface'
import { import {
ApplicationEvent, ApplicationEvent,
ApplicationService, ApplicationService,
@@ -169,9 +169,7 @@ export class ApplicationState extends ApplicationService {
override async onAppLaunch() { override async onAppLaunch() {
MobileApplication.setPreviouslyLaunched() MobileApplication.setPreviouslyLaunched()
this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true
await (this.application.deviceInterface as MobileDeviceInterface).setAndroidScreenshotPrivacy( await (this.application.deviceInterface as MobileDevice).setAndroidScreenshotPrivacy(this.screenshotPrivacyEnabled)
this.screenshotPrivacyEnabled,
)
} }
/** /**
@@ -480,28 +478,35 @@ export class ApplicationState extends ApplicationService {
private async checkAndLockApplication() { private async checkAndLockApplication() {
const isLocked = await this.application.isLocked() const isLocked = await this.application.isLocked()
if (!isLocked) {
const hasBiometrics = await this.application.hasBiometrics()
const hasPasscode = this.application.hasPasscode()
if (hasPasscode && this.passcodeTiming === MobileUnlockTiming.Immediately) {
await this.application.lock()
} else if (hasBiometrics && this.biometricsTiming === MobileUnlockTiming.Immediately && !this.locked) {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.Biometric)],
ChallengeReason.ApplicationUnlock,
false,
)
void this.application.promptForCustomChallenge(challenge)
this.locked = true if (isLocked) {
this.notifyLockStateObservers(LockStateType.Locked) return
this.application.addChallengeObserver(challenge, { }
onComplete: () => {
this.locked = false const hasBiometrics = this.application.hasBiometrics()
this.notifyLockStateObservers(LockStateType.Unlocked) const hasPasscode = this.application.hasPasscode()
}, const passcodeLockImmediately = hasPasscode && this.passcodeTiming === MobileUnlockTiming.Immediately
}) const biometricsLockImmediately =
} hasBiometrics && this.biometricsTiming === MobileUnlockTiming.Immediately && !this.locked
if (passcodeLockImmediately) {
await this.application.lock()
} else if (biometricsLockImmediately) {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.Biometric)],
ChallengeReason.ApplicationUnlock,
false,
)
void this.application.promptForCustomChallenge(challenge)
this.locked = true
this.notifyLockStateObservers(LockStateType.Locked)
this.application.addChallengeObserver(challenge, {
onComplete: () => {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
},
})
} }
} }
@@ -570,30 +575,26 @@ export class ApplicationState extends ApplicationService {
} }
private async getPasscodeTiming(): Promise<MobileUnlockTiming | undefined> { private async getPasscodeTiming(): Promise<MobileUnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise< return this.application.getMobilePasscodeTiming()
MobileUnlockTiming | undefined
>
} }
private async getBiometricsTiming(): Promise<MobileUnlockTiming | undefined> { private async getBiometricsTiming(): Promise<MobileUnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise< return this.application.getMobileBiometricsTiming()
MobileUnlockTiming | undefined
>
} }
public async setScreenshotPrivacyEnabled(enabled: boolean) { public async setScreenshotPrivacyEnabled(enabled: boolean) {
await this.application.setMobileScreenshotPrivacyEnabled(enabled) await this.application.setMobileScreenshotPrivacyEnabled(enabled)
this.screenshotPrivacyEnabled = enabled this.screenshotPrivacyEnabled = enabled
await (this.application.deviceInterface as MobileDeviceInterface).setAndroidScreenshotPrivacy(enabled) await (this.application.deviceInterface as MobileDevice).setAndroidScreenshotPrivacy(enabled)
} }
public async setPasscodeTiming(timing: MobileUnlockTiming) { public async setPasscodeTiming(timing: MobileUnlockTiming) {
await this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped) this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.passcodeTiming = timing this.passcodeTiming = timing
} }
public async setBiometricsTiming(timing: MobileUnlockTiming) { public async setBiometricsTiming(timing: MobileUnlockTiming) {
await this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped) this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.biometricsTiming = timing this.biometricsTiming = timing
} }
@@ -605,7 +606,7 @@ export class ApplicationState extends ApplicationService {
} }
public async setPasscodeKeyboardType(type: PasscodeKeyboardType) { public async setPasscodeKeyboardType(type: PasscodeKeyboardType) {
await this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped) this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped)
} }
public onDrawerOpen() { public onDrawerOpen() {

View File

@@ -1,6 +1,6 @@
import SNReactNative from '@standardnotes/react-native-utils' import SNReactNative from '@standardnotes/react-native-utils'
import { ApplicationService, ButtonType, StorageValueModes } from '@standardnotes/snjs' import { ApplicationService, ButtonType, StorageValueModes } from '@standardnotes/snjs'
import { MobileDeviceInterface } from './Interface' import { MobileDevice } from './Interface'
const FIRST_RUN_KEY = 'first_run' const FIRST_RUN_KEY = 'first_run'
@@ -23,7 +23,7 @@ export class InstallationService extends ApplicationService {
*/ */
async needsWipe() { async needsWipe() {
const hasAccountOrPasscode = this.application.hasAccount() || this.application?.hasPasscode() const hasAccountOrPasscode = this.application.hasAccount() || this.application?.hasPasscode()
const deviceInterface = this.application.deviceInterface as MobileDeviceInterface const deviceInterface = this.application.deviceInterface as MobileDevice
const keychainKey = await deviceInterface.getNamespacedKeychainValue(this.application.identifier) const keychainKey = await deviceInterface.getNamespacedKeychainValue(this.application.identifier)
const hasKeychainValue = keychainKey != undefined const hasKeychainValue = keychainKey != undefined

View File

@@ -2,21 +2,31 @@ import AsyncStorage from '@react-native-community/async-storage'
import SNReactNative from '@standardnotes/react-native-utils' import SNReactNative from '@standardnotes/react-native-utils'
import { import {
ApplicationIdentifier, ApplicationIdentifier,
DeviceInterface,
Environment, Environment,
LegacyMobileKeychainStructure, LegacyMobileKeychainStructure,
LegacyRawKeychainValue, LegacyRawKeychainValue,
MobileDeviceInterface,
NamespacedRootKeyInKeychain, NamespacedRootKeyInKeychain,
RawKeychainValue, RawKeychainValue,
removeFromArray,
TransferPayload, TransferPayload,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { Alert, Linking, Platform } from 'react-native' import { Alert, Linking, Platform } from 'react-native'
import FingerprintScanner from 'react-native-fingerprint-scanner' import FingerprintScanner from 'react-native-fingerprint-scanner'
import FlagSecure from 'react-native-flag-secure-android' import FlagSecure from 'react-native-flag-secure-android'
import { hide, show } from 'react-native-privacy-snapshot'
import { AppStateObserverService } from './../AppStateObserverService'
import Keychain from './Keychain' import Keychain from './Keychain'
import { IsMobileWeb } from './Utils'
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID' export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
export enum MobileDeviceEvent {
RequestsWebViewReload = 0,
}
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
/** /**
* This identifier was the database name used in Standard Notes web/desktop. * This identifier was the database name used in Standard Notes web/desktop.
*/ */
@@ -52,11 +62,16 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => {
Alert.alert('Unable to load item(s)', text) Alert.alert('Unable to load item(s)', text)
} }
export class MobileDeviceInterface implements DeviceInterface { export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile environment: Environment.Mobile = Environment.Mobile
private eventObservers: MobileDeviceEventHandler[] = []
// eslint-disable-next-line @typescript-eslint/no-empty-function constructor(private stateObserverService?: AppStateObserverService) {}
deinit() {}
deinit() {
this.stateObserverService?.deinit()
;(this.stateObserverService as unknown) = undefined
}
async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> { async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
await Keychain.setKeys(value) await Keychain.setKeys(value)
@@ -177,6 +192,14 @@ export class MobileDeviceInterface implements DeviceInterface {
} }
} }
hideMobileInterfaceFromScreenshots(): void {
hide()
}
stopHidingMobileInterfaceFromScreenshots(): void {
show()
}
async getAllRawStorageKeyValues() { async getAllRawStorageKeyValues() {
const keys = await AsyncStorage.getAllKeys() const keys = await AsyncStorage.getAllKeys()
return this.getRawStorageKeyValues(keys) return this.getRawStorageKeyValues(keys)
@@ -288,8 +311,10 @@ export class MobileDeviceInterface implements DeviceInterface {
} }
} }
authenticateWithBiometrics() { async authenticateWithBiometrics() {
return new Promise<boolean>((resolve) => { this.stateObserverService?.beginIgnoringStateChanges()
const result = await new Promise<boolean>((resolve) => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
FingerprintScanner.authenticate({ FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -333,10 +358,20 @@ export class MobileDeviceInterface implements DeviceInterface {
}) })
} }
}) })
this.stateObserverService?.stopIgnoringStateChanges()
return result
} }
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> { async getRawKeychainValue(): Promise<RawKeychainValue | undefined> {
return Keychain.getKeys() const result = await Keychain.getKeys()
if (result === null) {
return undefined
}
return result
} }
async clearRawKeychainValue(): Promise<void> { async clearRawKeychainValue(): Promise<void> {
@@ -375,7 +410,27 @@ export class MobileDeviceInterface implements DeviceInterface {
} }
performSoftReset() { performSoftReset() {
SNReactNative.exitApp() if (IsMobileWeb) {
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
} else {
SNReactNative.exitApp()
}
}
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.eventObservers.push(handler)
const thislessObservers = this.eventObservers
return () => {
removeFromArray(thislessObservers, handler)
}
}
private notifyEvent(event: MobileDeviceEvent): void {
for (const handler of this.eventObservers) {
handler(event)
}
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function

View File

@@ -1,7 +1,8 @@
import { TEnvironment } from '@Root/App' import { TEnvironment } from '@Root/NativeApp'
import VersionInfo from 'react-native-version-info' import VersionInfo from 'react-native-version-info'
export const IsDev = VersionInfo.bundleIdentifier?.includes('dev') export const IsDev = VersionInfo.bundleIdentifier?.includes('dev')
export const IsMobileWeb = IsDev
export function isNullOrUndefined(value: unknown) { export function isNullOrUndefined(value: unknown) {
return value === null || value === undefined return value === null || value === undefined

View File

@@ -0,0 +1,16 @@
import { navigationRef } from '@Lib/NavigationService'
import { NavigationContainer } from '@react-navigation/native'
import React from 'react'
import { MobileWebMainStackComponent } from './ModalStack'
const AppComponent: React.FC = () => {
return (
<NavigationContainer ref={navigationRef}>
<MobileWebMainStackComponent />
</NavigationContainer>
)
}
export const MobileWebApp = () => {
return <AppComponent />
}

View File

@@ -1,13 +1,50 @@
import { MobileDeviceInterface } from '@Lib/Interface' import { MobileDevice, MobileDeviceEvent } from '@Lib/Interface'
import React, { useMemo, useRef } from 'react' import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { WebView, WebViewMessageEvent } from 'react-native-webview' import { WebView, WebViewMessageEvent } from 'react-native-webview'
import { AppStateObserverService } from './AppStateObserverService'
const LoggingEnabled = false
export const MobileWebAppContainer = () => { export const MobileWebAppContainer = () => {
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' const [identifier, setIdentifier] = useState(Math.random())
const webViewRef = useRef<WebView>(null)
const destroyAndReload = useCallback(() => {
setIdentifier(Math.random())
}, [])
return <MobileWebAppContents key={`${identifier}`} destroyAndReload={destroyAndReload} />
}
const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => {
const webViewRef = useRef<WebView>(null)
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const stateService = useMemo(() => new AppStateObserverService(), [])
const device = useMemo(() => new MobileDevice(stateService), [stateService])
useEffect(() => {
const removeListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' }))
})
return () => {
removeListener()
}
}, [webViewRef, stateService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
if (event === MobileDeviceEvent.RequestsWebViewReload) {
destroyAndReload()
}
})
return () => {
observer()
}
}, [device, destroyAndReload])
const device = useMemo(() => new MobileDeviceInterface(), [])
const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(device)) const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(device))
const baselineFunctions: Record<string, any> = { const baselineFunctions: Record<string, any> = {
@@ -28,7 +65,7 @@ export const MobileWebAppContainer = () => {
stringFunctions += ` stringFunctions += `
${functionName}(...args) { ${functionName}(...args) {
return this.sendMessage('${functionName}', args); return this.askReactNativeToInvokeInterfaceMethod('${functionName}', args);
} }
` `
} }
@@ -44,8 +81,8 @@ export const MobileWebAppContainer = () => {
setApplication() {} setApplication() {}
sendMessage(functionName, args) { askReactNativeToInvokeInterfaceMethod(functionName, args) {
return this.messageSender.sendMessage(functionName, args) return this.messageSender.askReactNativeToInvokeInterfaceMethod(functionName, args)
} }
${stringFunctions} ${stringFunctions}
@@ -56,26 +93,18 @@ export const MobileWebAppContainer = () => {
class WebProcessMessageSender { class WebProcessMessageSender {
constructor() { constructor() {
this.pendingMessages = [] this.pendingMessages = []
window.addEventListener('message', this.handleMessageFromReactNative.bind(this))
document.addEventListener('message', this.handleMessageFromReactNative.bind(this))
} }
handleMessageFromReactNative(event) { handleReplyFromReactNative( messageId, returnValue) {
const message = event.data const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId)
try { pendingMessage.resolve(returnValue)
const parsed = JSON.parse(message) this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1)
const { messageId, returnValue } = parsed
const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId)
pendingMessage.resolve(returnValue)
this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1)
} catch (error) {
console.log('Error parsing message from React Native', message, error)
}
} }
sendMessage(functionName, args) { askReactNativeToInvokeInterfaceMethod(functionName, args) {
const messageId = Math.random() const messageId = Math.random()
window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId })) window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId }))
return new Promise((resolve) => { return new Promise((resolve) => {
this.pendingMessages.push({ this.pendingMessages.push({
messageId, messageId,
@@ -98,6 +127,25 @@ export const MobileWebAppContainer = () => {
const messageSender = new WebProcessMessageSender(); const messageSender = new WebProcessMessageSender();
window.reactNativeDevice = new WebProcessDeviceInterface(messageSender); window.reactNativeDevice = new WebProcessDeviceInterface(messageSender);
const handleMessageFromReactNative = (event) => {
const message = event.data
try {
const parsed = JSON.parse(message)
const { messageId, returnValue, messageType } = parsed
if (messageType === 'reply') {
messageSender.handleReplyFromReactNative(messageId, returnValue)
}
} catch (error) {
console.log('Error parsing message from React Native', message, error)
}
}
window.addEventListener('message', handleMessageFromReactNative)
document.addEventListener('message', handleMessageFromReactNative)
true; true;
` `
@@ -107,14 +155,18 @@ export const MobileWebAppContainer = () => {
const functionData = JSON.parse(message) const functionData = JSON.parse(message)
void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args) void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args)
} catch (error) { } catch (error) {
console.log('onGeneralMessage', JSON.stringify(message)) if (LoggingEnabled) {
console.log('onGeneralMessage', JSON.stringify(message))
}
} }
} }
const onFunctionMessage = async (functionName: string, messageId: string, args: any) => { const onFunctionMessage = async (functionName: string, messageId: string, args: any) => {
const returnValue = await (device as any)[functionName](...args) const returnValue = await (device as any)[functionName](...args)
console.log(`Native device function ${functionName} called`) if (LoggingEnabled) {
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue })) console.log(`Native device function ${functionName} called`)
}
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' }))
} }
/* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-empty-function */

View File

@@ -30,11 +30,11 @@ import React, { memo, useContext } from 'react'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import { HeaderTitleParams, TEnvironment } from './App'
import { ApplicationContext } from './ApplicationContext' import { ApplicationContext } from './ApplicationContext'
import { AppStackComponent } from './AppStack' import { AppStackComponent } from './AppStack'
import { HistoryStack } from './HistoryStack' import { HistoryStack } from './HistoryStack'
import { MobileWebAppContainer } from './MobileWebAppContainer' import { MobileWebAppContainer } from './MobileWebAppContainer'
import { HeaderTitleParams, TEnvironment } from './NativeApp'
export type ModalStackNavigatorParamList = { export type ModalStackNavigatorParamList = {
AppStack: undefined AppStack: undefined
@@ -75,7 +75,31 @@ export type ModalStackNavigationProp<T extends keyof ModalStackNavigatorParamLis
const MainStack = createStackNavigator<ModalStackNavigatorParamList>() const MainStack = createStackNavigator<ModalStackNavigatorParamList>()
export const MainStackComponent = ({ env }: { env: TEnvironment }) => { export const MobileWebMainStackComponent = () => {
const MemoizedAppStackComponent = memo((props: ModalStackNavigationProp<'AppStack'>) => (
<AppStackComponent {...props} />
))
return (
<MainStack.Navigator
screenOptions={{
gestureEnabled: false,
presentation: 'modal',
}}
initialRouteName="AppStack"
>
<MainStack.Screen
name={'AppStack'}
options={{
headerShown: false,
}}
component={MemoizedAppStackComponent}
/>
</MainStack.Navigator>
)
}
export const NativeMainStackComponent = ({ env }: { env: TEnvironment }) => {
const application = useContext(ApplicationContext) const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)

View File

@@ -11,7 +11,7 @@ import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { ThemeProvider } from 'styled-components/native' import { ThemeProvider } from 'styled-components/native'
import { ApplicationContext } from './ApplicationContext' import { ApplicationContext } from './ApplicationContext'
import { MainStackComponent } from './ModalStack' import { NativeMainStackComponent } from './ModalStack'
export type HeaderTitleParams = { export type HeaderTitleParams = {
title?: string title?: string
@@ -111,7 +111,7 @@ const AppComponent: React.FC<{
<ThemeProvider theme={activeTheme}> <ThemeProvider theme={activeTheme}>
<ActionSheetProvider> <ActionSheetProvider>
<ThemeServiceContext.Provider value={themeService.current}> <ThemeServiceContext.Provider value={themeService.current}>
<MainStackComponent env={env} /> <NativeMainStackComponent env={env} />
</ThemeServiceContext.Provider> </ThemeServiceContext.Provider>
</ActionSheetProvider> </ActionSheetProvider>
<ToastWrapper /> <ToastWrapper />
@@ -122,7 +122,7 @@ const AppComponent: React.FC<{
) )
} }
export const App = (props: { env: TEnvironment }) => { export const NativeApp = (props: { env: TEnvironment }) => {
const [application, setApplication] = useState<MobileApplication | undefined>() const [application, setApplication] = useState<MobileApplication | undefined>()
const createNewAppGroup = useCallback(() => { const createNewAppGroup = useCallback(() => {

View File

@@ -1,5 +1,5 @@
import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState' import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState'
import { MobileDeviceInterface } from '@Lib/Interface' import { MobileDevice } from '@Lib/Interface'
import { HeaderHeightContext } from '@react-navigation/elements' import { HeaderHeightContext } from '@react-navigation/elements'
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext' import { ApplicationContext } from '@Root/ApplicationContext'
@@ -164,7 +164,7 @@ export const Authenticate = ({
}, []) }, [])
const checkForBiometrics = useCallback( const checkForBiometrics = useCallback(
async () => (application?.deviceInterface as MobileDeviceInterface).getDeviceBiometricsAvailability(), async () => (application?.deviceInterface as MobileDevice).getDeviceBiometricsAvailability(),
[application], [application],
) )

View File

@@ -1,4 +1,4 @@
import { MobileDeviceInterface } from '@Lib/Interface' import { MobileDevice } from '@Lib/Interface'
import { useFocusEffect, useNavigation } from '@react-navigation/native' import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext' import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell' import { ButtonCell } from '@Root/Components/ButtonCell'
@@ -53,7 +53,7 @@ export const SecuritySection = (props: Props) => {
void getHasBiometrics() void getHasBiometrics()
const hasBiometricsSupport = async () => { const hasBiometricsSupport = async () => {
const hasBiometricsAvailable = await ( const hasBiometricsAvailable = await (
application?.deviceInterface as MobileDeviceInterface application?.deviceInterface as MobileDevice
).getDeviceBiometricsAvailability() ).getDeviceBiometricsAvailability()
if (mounted) { if (mounted) {
setSupportsBiometrics(hasBiometricsAvailable) setSupportsBiometrics(hasBiometricsAvailable)

View File

@@ -42,6 +42,7 @@
"eslint": "^8.23.1", "eslint": "^8.23.1",
"eslint-plugin-prettier": "*", "eslint-plugin-prettier": "*",
"jest": "^28.1.2", "jest": "^28.1.2",
"ts-jest": "^28.0.5" "ts-jest": "^28.0.5",
"typescript": "*"
} }
} }

View File

@@ -5,4 +5,8 @@ import { ApplicationInterface } from './ApplicationInterface'
export interface WebApplicationInterface extends ApplicationInterface { export interface WebApplicationInterface extends ApplicationInterface {
notifyWebEvent(event: WebAppEvent, data?: unknown): void notifyWebEvent(event: WebAppEvent, data?: unknown): void
getDesktopService(): DesktopManagerInterface | undefined getDesktopService(): DesktopManagerInterface | undefined
handleMobileEnteringBackgroundEvent(): Promise<void>
handleMobileGainingFocusEvent(): Promise<void>
handleMobileLosingFocusEvent(): Promise<void>
handleMobileResumingFromBackgroundEvent(): Promise<void>
} }

View File

@@ -7,7 +7,7 @@ export interface MobileDeviceInterface extends DeviceInterface {
getRawKeychainValue(): Promise<RawKeychainValue | undefined> getRawKeychainValue(): Promise<RawKeychainValue | undefined>
getDeviceBiometricsAvailability(): Promise<boolean> getDeviceBiometricsAvailability(): Promise<boolean>
setAndroidScreenshotPrivacy(enable: boolean): Promise<void> setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
authenticateWithBiometrics(): Promise<boolean> authenticateWithBiometrics(): Promise<boolean>
hideMobileInterfaceFromScreenshots(): void
stopHidingMobileInterfaceFromScreenshots(): void
} }

View File

@@ -41,6 +41,7 @@ import {
FileService, FileService,
SubscriptionClientInterface, SubscriptionClientInterface,
SubscriptionManager, SubscriptionManager,
StorageValueModes,
} from '@standardnotes/services' } from '@standardnotes/services'
import { FilesClientInterface } from '@standardnotes/files' import { FilesClientInterface } from '@standardnotes/files'
import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption' import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption'
@@ -60,6 +61,7 @@ import { SNLog } from '../Log'
import { Challenge, ChallengeResponse } from '../Services' import { Challenge, ChallengeResponse } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults' import { ApplicationOptionsDefaults } from './Options/Defaults'
import { MobileUnlockTiming } from '@Lib/Services/Protection/MobileUnlockTiming'
/** How often to automatically sync, in milliseconds */ /** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -927,7 +929,7 @@ export class SNApplication
return this.deinit(this.getDeinitMode(), DeinitSource.Lock) return this.deinit(this.getDeinitMode(), DeinitSource.Lock)
} }
async setBiometricsTiming(timing: InternalServices.MobileUnlockTiming) { async setBiometricsTiming(timing: MobileUnlockTiming) {
return this.protectionService.setBiometricsTiming(timing) return this.protectionService.setBiometricsTiming(timing)
} }
@@ -935,6 +937,18 @@ export class SNApplication
return this.protectionService.getMobileScreenshotPrivacyEnabled() return this.protectionService.getMobileScreenshotPrivacyEnabled()
} }
async getMobilePasscodeTiming(): Promise<MobileUnlockTiming | undefined> {
return this.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
}
async getMobileBiometricsTiming(): Promise<MobileUnlockTiming | undefined> {
return this.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
}
async setMobileScreenshotPrivacyEnabled(isEnabled: boolean) { async setMobileScreenshotPrivacyEnabled(isEnabled: boolean) {
return this.protectionService.setMobileScreenshotPrivacyEnabled(isEnabled) return this.protectionService.setMobileScreenshotPrivacyEnabled(isEnabled)
} }

View File

@@ -0,0 +1,6 @@
export enum ReactNativeToWebEvent {
EnteringBackground = 'EnteringBackground',
ResumingFromBackground = 'ResumingFromBackground',
GainingFocus = 'GainingFocus',
LosingFocus = 'LosingFocus',
}

View File

@@ -2,3 +2,4 @@ export * from './IconsController'
export * from './NoteViewController' export * from './NoteViewController'
export * from './FileViewController' export * from './FileViewController'
export * from './ItemGroupController' export * from './ItemGroupController'
export * from './ReactNativeToWebEvent'

View File

@@ -0,0 +1,4 @@
export enum MobileUnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}

View File

@@ -18,17 +18,13 @@ import {
} from '@standardnotes/services' } from '@standardnotes/services'
import { ProtectionsClientInterface } from './ClientInterface' import { ProtectionsClientInterface } from './ClientInterface'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { MobileUnlockTiming } from './MobileUnlockTiming'
export enum ProtectionEvent { export enum ProtectionEvent {
UnprotectedSessionBegan = 'UnprotectedSessionBegan', UnprotectedSessionBegan = 'UnprotectedSessionBegan',
UnprotectedSessionExpired = 'UnprotectedSessionExpired', UnprotectedSessionExpired = 'UnprotectedSessionExpired',
} }
export enum MobileUnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}
export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30 export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30
export enum UnprotectedAccessSecondsDuration { export enum UnprotectedAccessSecondsDuration {

View File

@@ -1,2 +1,3 @@
export * from './ClientInterface' export * from './ClientInterface'
export * from './ProtectionService' export * from './ProtectionService'
export * from './MobileUnlockTiming'

View File

@@ -17,12 +17,15 @@ import {
DecryptedItemInterface, DecryptedItemInterface,
WebAppEvent, WebAppEvent,
WebApplicationInterface, WebApplicationInterface,
MobileDeviceInterface,
MobileUnlockTiming,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx' import { makeObservable, observable } from 'mobx'
import { PanelResizedData } from '@/Types/PanelResizedData' import { PanelResizedData } from '@/Types/PanelResizedData'
import { isDesktopApplication } from '@/Utils' import { isDesktopApplication } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager' import { DesktopManager } from './Device/DesktopManager'
import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services' import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services'
import { MobileWebReceiver } from './MobileWebReceiver'
type WebServices = { type WebServices = {
viewControllerManager: ViewControllerManager viewControllerManager: ViewControllerManager
@@ -41,6 +44,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
public itemControllerGroup: ItemGroupController public itemControllerGroup: ItemGroupController
public iconsController: IconsController public iconsController: IconsController
private onVisibilityChange: () => void private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver
constructor( constructor(
deviceInterface: WebOrDesktopDevice, deviceInterface: WebOrDesktopDevice,
@@ -70,6 +74,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.itemControllerGroup = new ItemGroupController(this) this.itemControllerGroup = new ItemGroupController(this)
this.iconsController = new IconsController() this.iconsController = new IconsController()
if (this.isNativeMobileWeb()) {
this.mobileWebReceiver = new MobileWebReceiver(this)
}
this.onVisibilityChange = () => { this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible' const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
@@ -101,6 +109,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.itemControllerGroup.deinit() this.itemControllerGroup.deinit()
;(this.itemControllerGroup as unknown) = undefined ;(this.itemControllerGroup as unknown) = undefined
;(this.mobileWebReceiver as unknown) = undefined
this.webEventObservers.length = 0 this.webEventObservers.length = 0
@@ -161,6 +170,13 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return undefined return undefined
} }
get mobileDevice(): MobileDeviceInterface {
if (!this.isNativeMobileWeb()) {
throw Error('Attempting to access device as mobile device on non mobile platform')
}
return this.deviceInterface as MobileDeviceInterface
}
public getThemeService() { public getThemeService() {
return this.webServices.themeService return this.webServices.themeService
} }
@@ -203,4 +219,44 @@ export class WebApplication extends SNApplication implements WebApplicationInter
const currentValue = this.isGlobalSpellcheckEnabled() const currentValue = this.isGlobalSpellcheckEnabled()
return this.setPreference(PrefKey.EditorSpellcheck, !currentValue) return this.setPreference(PrefKey.EditorSpellcheck, !currentValue)
} }
async handleMobileEnteringBackgroundEvent(): Promise<void> {
await this.lockApplicationAfterMobileEventIfApplicable()
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async handleMobileGainingFocusEvent(): Promise<void> {}
async handleMobileLosingFocusEvent(): Promise<void> {
if (await this.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice.stopHidingMobileInterfaceFromScreenshots()
}
await this.lockApplicationAfterMobileEventIfApplicable()
}
async handleMobileResumingFromBackgroundEvent(): Promise<void> {
if (await this.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice.hideMobileInterfaceFromScreenshots()
}
}
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
const isLocked = await this.isLocked()
if (isLocked) {
return
}
const hasBiometrics = this.hasBiometrics()
const hasPasscode = this.hasPasscode()
const passcodeTiming = await this.getMobilePasscodeTiming()
const biometricsTiming = await this.getMobileBiometricsTiming()
const passcodeLockImmediately = hasPasscode && passcodeTiming === MobileUnlockTiming.Immediately
const biometricsLockImmediately = hasBiometrics && biometricsTiming === MobileUnlockTiming.Immediately
if (passcodeLockImmediately || biometricsLockImmediately) {
await this.lock()
}
}
} }

View File

@@ -0,0 +1,61 @@
import { ReactNativeToWebEvent, WebApplicationInterface } from '@standardnotes/snjs'
export class MobileWebReceiver {
constructor(private application: WebApplicationInterface) {
this.listenForNativeMobileEvents()
}
deinit() {
;(this.application as unknown) = undefined
window.removeEventListener('message', this.handleNativeMobileWindowMessage)
document.removeEventListener('message', this.handleNativeMobileWindowMessage as never)
}
listenForNativeMobileEvents() {
const iOSEventRecipient = window
const androidEventRecipient = document
iOSEventRecipient.addEventListener('message', this.handleNativeMobileWindowMessage)
androidEventRecipient.addEventListener('message', this.handleNativeMobileWindowMessage as never)
}
handleNativeMobileWindowMessage = (event: MessageEvent) => {
const nullOrigin = event.origin === '' || event.origin == null
if (!nullOrigin) {
return
}
const message = (event as MessageEvent).data
try {
const parsed = JSON.parse(message)
const { messageType, reactNativeEvent } = parsed
if (messageType === 'event' && reactNativeEvent) {
const nativeEvent = reactNativeEvent as ReactNativeToWebEvent
this.handleNativeEvent(nativeEvent)
}
} catch (error) {
console.log('Error parsing message from React Native', error)
}
}
handleNativeEvent(event: ReactNativeToWebEvent) {
switch (event) {
case ReactNativeToWebEvent.EnteringBackground:
void this.application.handleMobileEnteringBackgroundEvent()
break
case ReactNativeToWebEvent.GainingFocus:
void this.application.handleMobileGainingFocusEvent()
break
case ReactNativeToWebEvent.LosingFocus:
void this.application.handleMobileLosingFocusEvent()
break
case ReactNativeToWebEvent.ResumingFromBackground:
void this.application.handleMobileResumingFromBackgroundEvent()
break
default:
break
}
}
}

View File

@@ -7370,6 +7370,7 @@ __metadata:
jest: ^28.1.2 jest: ^28.1.2
reflect-metadata: ^0.1.13 reflect-metadata: ^0.1.13
ts-jest: ^28.0.5 ts-jest: ^28.0.5
typescript: "*"
languageName: unknown languageName: unknown
linkType: soft linkType: soft