From c4d776149677269bc766f24da6adc5ec816dbcfb Mon Sep 17 00:00:00 2001 From: Mo Date: Mon, 19 Sep 2022 14:47:15 -0500 Subject: [PATCH] feat: mobile web bridge (#1597) --- packages/mobile/index.js | 8 +- packages/mobile/src/AppStack.tsx | 6 +- .../mobile/src/AppStateObserverService.ts | 62 +++++++++++ packages/mobile/src/HistoryStack.tsx | 2 +- packages/mobile/src/Lib/Application.ts | 8 +- packages/mobile/src/Lib/ApplicationGroup.ts | 6 +- packages/mobile/src/Lib/ApplicationState.ts | 71 ++++++------ .../mobile/src/Lib/InstallationService.ts | 4 +- packages/mobile/src/Lib/Interface.ts | 73 +++++++++++-- packages/mobile/src/Lib/Utils.ts | 3 +- packages/mobile/src/MobileWebApp.tsx | 16 +++ packages/mobile/src/MobileWebAppContainer.tsx | 102 +++++++++++++----- packages/mobile/src/ModalStack.tsx | 28 ++++- .../mobile/src/{App.tsx => NativeApp.tsx} | 6 +- .../src/Screens/Authenticate/Authenticate.tsx | 4 +- .../Settings/Sections/SecuritySection.tsx | 4 +- packages/services/package.json | 3 +- .../Application/WebApplicationInterface.ts | 4 + .../Domain/Device/MobileDeviceInterface.ts | 4 +- packages/snjs/lib/Application/Application.ts | 16 ++- .../snjs/lib/Client/ReactNativeToWebEvent.ts | 6 ++ packages/snjs/lib/Client/index.ts | 1 + .../Services/Protection/MobileUnlockTiming.ts | 4 + .../Services/Protection/ProtectionService.ts | 6 +- .../snjs/lib/Services/Protection/index.ts | 1 + .../javascripts/Application/Application.ts | 56 ++++++++++ .../Application/MobileWebReceiver.ts | 61 +++++++++++ yarn.lock | 1 + 28 files changed, 462 insertions(+), 104 deletions(-) create mode 100644 packages/mobile/src/AppStateObserverService.ts create mode 100644 packages/mobile/src/MobileWebApp.tsx rename packages/mobile/src/{App.tsx => NativeApp.tsx} (96%) create mode 100644 packages/snjs/lib/Client/ReactNativeToWebEvent.ts create mode 100644 packages/snjs/lib/Services/Protection/MobileUnlockTiming.ts create mode 100644 packages/web/src/javascripts/Application/MobileWebReceiver.ts diff --git a/packages/mobile/index.js b/packages/mobile/index.js index 5262bd969..284de9ff7 100644 --- a/packages/mobile/index.js +++ b/packages/mobile/index.js @@ -1,10 +1,12 @@ +import { IsMobileWeb } from '@Lib/Utils' +import { MobileWebApp } from '@Root/MobileWebApp' import { SNLog } from '@standardnotes/snjs' import { AppRegistry } from 'react-native' import 'react-native-gesture-handler' import { enableScreens } from 'react-native-screens' import 'react-native-url-polyfill/auto' 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' 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", ] - if (!supressedWarnings.some(entry => msg.includes(entry))) { + if (!supressedWarnings.some((entry) => msg.includes(entry))) { originalWarn.apply(console, arguments) } } enableAndroidFontFix() -AppRegistry.registerComponent(appName, () => App) +AppRegistry.registerComponent(appName, () => (IsMobileWeb ? MobileWebApp : NativeApp)) diff --git a/packages/mobile/src/AppStack.tsx b/packages/mobile/src/AppStack.tsx index f7efc6d80..86fdbdc03 100644 --- a/packages/mobile/src/AppStack.tsx +++ b/packages/mobile/src/AppStack.tsx @@ -2,7 +2,7 @@ import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/Appl import { AlwaysOpenWebAppOnLaunchKey } from '@Lib/constants' import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks' import { ScreenStatus } from '@Lib/StatusManager' -import { IsDev } from '@Lib/Utils' +import { IsMobileWeb } from '@Lib/Utils' import { CompositeNavigationProp, RouteProp } from '@react-navigation/native' import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack' 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 { HeaderButtons, Item } from 'react-navigation-header-buttons' import { ThemeContext } from 'styled-components' -import { HeaderTitleParams } from './App' import { ApplicationContext } from './ApplicationContext' import { MobileWebAppContainer } from './MobileWebAppContainer' import { ModalStackNavigationProp } from './ModalStack' +import { HeaderTitleParams } from './NativeApp' export type AppStackNavigatorParamList = { [SCREEN_NOTES]: HeaderTitleParams @@ -121,7 +121,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) = [application], ) - if (IsDev) { + if (IsMobileWeb) { return ( ({ diff --git a/packages/mobile/src/AppStateObserverService.ts b/packages/mobile/src/AppStateObserverService.ts new file mode 100644 index 000000000..0a18ce151 --- /dev/null +++ b/packages/mobile/src/AppStateObserverService.ts @@ -0,0 +1,62 @@ +import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs' +import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native' + +export class AppStateObserverService extends AbstractService { + 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) + } +} diff --git a/packages/mobile/src/HistoryStack.tsx b/packages/mobile/src/HistoryStack.tsx index 01af9e441..b6f8e1d4b 100644 --- a/packages/mobile/src/HistoryStack.tsx +++ b/packages/mobile/src/HistoryStack.tsx @@ -12,7 +12,7 @@ import React, { useContext } from 'react' import { Platform } from 'react-native' import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { ThemeContext } from 'styled-components' -import { HeaderTitleParams } from './App' +import { HeaderTitleParams } from './NativeApp' type HistoryStackNavigatorParamList = { [SCREEN_NOTE_HISTORY]: (HeaderTitleParams & { noteUuid: string }) | (undefined & { noteUuid: string }) diff --git a/packages/mobile/src/Lib/Application.ts b/packages/mobile/src/Lib/Application.ts index c658b9398..e299f185f 100644 --- a/packages/mobile/src/Lib/Application.ts +++ b/packages/mobile/src/Lib/Application.ts @@ -23,13 +23,13 @@ import { BackupsService } from './BackupsService' import { ComponentManager } from './ComponentManager' import { FilesService } from './FilesService' import { InstallationService } from './InstallationService' -import { MobileDeviceInterface } from './Interface' +import { MobileDevice } from './Interface' import { push } from './NavigationService' import { PreferencesManager } from './PreferencesManager' import { SNReactNativeCrypto } from './ReactNativeCrypto' import { ReviewService } from './ReviewService' import { StatusManager } from './StatusManager' -import { IsDev } from './Utils' +import { IsDev, IsMobileWeb } from './Utils' type MobileServices = { applicationState: ApplicationState @@ -52,7 +52,7 @@ export class MobileApplication extends SNApplication { static previouslyLaunched = false - constructor(deviceInterface: MobileDeviceInterface, identifier: string) { + constructor(deviceInterface: MobileDevice, identifier: string) { super({ environment: Environment.Mobile, platform: platformFromString(Platform.OS), @@ -135,7 +135,7 @@ export class MobileApplication extends SNApplication { } promptForChallenge(challenge: Challenge) { - if (IsDev) { + if (IsMobileWeb) { return } diff --git a/packages/mobile/src/Lib/ApplicationGroup.ts b/packages/mobile/src/Lib/ApplicationGroup.ts index cde237bd1..fb7e24f00 100644 --- a/packages/mobile/src/Lib/ApplicationGroup.ts +++ b/packages/mobile/src/Lib/ApplicationGroup.ts @@ -4,14 +4,14 @@ import { ApplicationState } from './ApplicationState' import { BackupsService } from './BackupsService' import { FilesService } from './FilesService' import { InstallationService } from './InstallationService' -import { MobileDeviceInterface } from './Interface' +import { MobileDevice } from './Interface' import { PreferencesManager } from './PreferencesManager' import { ReviewService } from './ReviewService' import { StatusManager } from './StatusManager' export class ApplicationGroup extends SNApplicationGroup { constructor() { - super(new MobileDeviceInterface()) + super(new MobileDevice()) } override async initialize(_callback?: any): Promise { @@ -21,7 +21,7 @@ export class ApplicationGroup extends SNApplicationGroup { } 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 applicationState = new ApplicationState(application) const reviewService = new ReviewService(application, internalEventBus) diff --git a/packages/mobile/src/Lib/ApplicationState.ts b/packages/mobile/src/Lib/ApplicationState.ts index 22aeca21f..2102d5a9a 100644 --- a/packages/mobile/src/Lib/ApplicationState.ts +++ b/packages/mobile/src/Lib/ApplicationState.ts @@ -1,4 +1,4 @@ -import { MobileDeviceInterface } from '@Lib/Interface' +import { MobileDevice } from '@Lib/Interface' import { ApplicationEvent, ApplicationService, @@ -169,9 +169,7 @@ export class ApplicationState extends ApplicationService { override async onAppLaunch() { MobileApplication.setPreviouslyLaunched() this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true - await (this.application.deviceInterface as MobileDeviceInterface).setAndroidScreenshotPrivacy( - this.screenshotPrivacyEnabled, - ) + await (this.application.deviceInterface as MobileDevice).setAndroidScreenshotPrivacy(this.screenshotPrivacyEnabled) } /** @@ -480,28 +478,35 @@ export class ApplicationState extends ApplicationService { private async checkAndLockApplication() { 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 - this.notifyLockStateObservers(LockStateType.Locked) - this.application.addChallengeObserver(challenge, { - onComplete: () => { - this.locked = false - this.notifyLockStateObservers(LockStateType.Unlocked) - }, - }) - } + if (isLocked) { + return + } + + const hasBiometrics = this.application.hasBiometrics() + 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 { - return this.application.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise< - MobileUnlockTiming | undefined - > + return this.application.getMobilePasscodeTiming() } private async getBiometricsTiming(): Promise { - return this.application.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise< - MobileUnlockTiming | undefined - > + return this.application.getMobileBiometricsTiming() } public async setScreenshotPrivacyEnabled(enabled: boolean) { await this.application.setMobileScreenshotPrivacyEnabled(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) { - await this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped) + this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped) this.passcodeTiming = timing } 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 } @@ -605,7 +606,7 @@ export class ApplicationState extends ApplicationService { } public async setPasscodeKeyboardType(type: PasscodeKeyboardType) { - await this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped) + this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped) } public onDrawerOpen() { diff --git a/packages/mobile/src/Lib/InstallationService.ts b/packages/mobile/src/Lib/InstallationService.ts index 878c3ff7a..a3ed50824 100644 --- a/packages/mobile/src/Lib/InstallationService.ts +++ b/packages/mobile/src/Lib/InstallationService.ts @@ -1,6 +1,6 @@ import SNReactNative from '@standardnotes/react-native-utils' import { ApplicationService, ButtonType, StorageValueModes } from '@standardnotes/snjs' -import { MobileDeviceInterface } from './Interface' +import { MobileDevice } from './Interface' const FIRST_RUN_KEY = 'first_run' @@ -23,7 +23,7 @@ export class InstallationService extends ApplicationService { */ async needsWipe() { 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 hasKeychainValue = keychainKey != undefined diff --git a/packages/mobile/src/Lib/Interface.ts b/packages/mobile/src/Lib/Interface.ts index 4e6931064..3f9d36e3e 100644 --- a/packages/mobile/src/Lib/Interface.ts +++ b/packages/mobile/src/Lib/Interface.ts @@ -2,21 +2,31 @@ import AsyncStorage from '@react-native-community/async-storage' import SNReactNative from '@standardnotes/react-native-utils' import { ApplicationIdentifier, - DeviceInterface, Environment, LegacyMobileKeychainStructure, LegacyRawKeychainValue, + MobileDeviceInterface, NamespacedRootKeyInKeychain, RawKeychainValue, + removeFromArray, TransferPayload, } from '@standardnotes/snjs' import { Alert, Linking, Platform } from 'react-native' import FingerprintScanner from 'react-native-fingerprint-scanner' 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 { IsMobileWeb } from './Utils' 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. */ @@ -52,11 +62,16 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => { Alert.alert('Unable to load item(s)', text) } -export class MobileDeviceInterface implements DeviceInterface { +export class MobileDevice implements MobileDeviceInterface { environment: Environment.Mobile = Environment.Mobile + private eventObservers: MobileDeviceEventHandler[] = [] - // eslint-disable-next-line @typescript-eslint/no-empty-function - deinit() {} + constructor(private stateObserverService?: AppStateObserverService) {} + + deinit() { + this.stateObserverService?.deinit() + ;(this.stateObserverService as unknown) = undefined + } async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise { await Keychain.setKeys(value) @@ -177,6 +192,14 @@ export class MobileDeviceInterface implements DeviceInterface { } } + hideMobileInterfaceFromScreenshots(): void { + hide() + } + + stopHidingMobileInterfaceFromScreenshots(): void { + show() + } + async getAllRawStorageKeyValues() { const keys = await AsyncStorage.getAllKeys() return this.getRawStorageKeyValues(keys) @@ -288,8 +311,10 @@ export class MobileDeviceInterface implements DeviceInterface { } } - authenticateWithBiometrics() { - return new Promise((resolve) => { + async authenticateWithBiometrics() { + this.stateObserverService?.beginIgnoringStateChanges() + + const result = await new Promise((resolve) => { if (Platform.OS === 'android') { FingerprintScanner.authenticate({ // 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 { - return Keychain.getKeys() + async getRawKeychainValue(): Promise { + const result = await Keychain.getKeys() + + if (result === null) { + return undefined + } + + return result } async clearRawKeychainValue(): Promise { @@ -375,7 +410,27 @@ export class MobileDeviceInterface implements DeviceInterface { } 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 diff --git a/packages/mobile/src/Lib/Utils.ts b/packages/mobile/src/Lib/Utils.ts index 0ee35b108..b44b32e6e 100644 --- a/packages/mobile/src/Lib/Utils.ts +++ b/packages/mobile/src/Lib/Utils.ts @@ -1,7 +1,8 @@ -import { TEnvironment } from '@Root/App' +import { TEnvironment } from '@Root/NativeApp' import VersionInfo from 'react-native-version-info' export const IsDev = VersionInfo.bundleIdentifier?.includes('dev') +export const IsMobileWeb = IsDev export function isNullOrUndefined(value: unknown) { return value === null || value === undefined diff --git a/packages/mobile/src/MobileWebApp.tsx b/packages/mobile/src/MobileWebApp.tsx new file mode 100644 index 000000000..862f55b15 --- /dev/null +++ b/packages/mobile/src/MobileWebApp.tsx @@ -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 ( + + + + ) +} + +export const MobileWebApp = () => { + return +} diff --git a/packages/mobile/src/MobileWebAppContainer.tsx b/packages/mobile/src/MobileWebAppContainer.tsx index 205a26db0..e66eea90d 100644 --- a/packages/mobile/src/MobileWebAppContainer.tsx +++ b/packages/mobile/src/MobileWebAppContainer.tsx @@ -1,13 +1,50 @@ -import { MobileDeviceInterface } from '@Lib/Interface' -import React, { useMemo, useRef } from 'react' +import { MobileDevice, MobileDeviceEvent } from '@Lib/Interface' +import { ReactNativeToWebEvent } from '@standardnotes/snjs' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Platform } from 'react-native' import { WebView, WebViewMessageEvent } from 'react-native-webview' +import { AppStateObserverService } from './AppStateObserverService' + +const LoggingEnabled = false export const MobileWebAppContainer = () => { - const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html' - const webViewRef = useRef(null) + const [identifier, setIdentifier] = useState(Math.random()) + + const destroyAndReload = useCallback(() => { + setIdentifier(Math.random()) + }, []) + + return +} + +const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => { + const webViewRef = useRef(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 baselineFunctions: Record = { @@ -28,7 +65,7 @@ export const MobileWebAppContainer = () => { stringFunctions += ` ${functionName}(...args) { - return this.sendMessage('${functionName}', args); + return this.askReactNativeToInvokeInterfaceMethod('${functionName}', args); } ` } @@ -44,8 +81,8 @@ export const MobileWebAppContainer = () => { setApplication() {} - sendMessage(functionName, args) { - return this.messageSender.sendMessage(functionName, args) + askReactNativeToInvokeInterfaceMethod(functionName, args) { + return this.messageSender.askReactNativeToInvokeInterfaceMethod(functionName, args) } ${stringFunctions} @@ -56,26 +93,18 @@ export const MobileWebAppContainer = () => { class WebProcessMessageSender { constructor() { this.pendingMessages = [] - window.addEventListener('message', this.handleMessageFromReactNative.bind(this)) - document.addEventListener('message', this.handleMessageFromReactNative.bind(this)) } - handleMessageFromReactNative(event) { - const message = event.data - try { - const parsed = JSON.parse(message) - 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) - } + handleReplyFromReactNative( messageId, returnValue) { + const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId) + pendingMessage.resolve(returnValue) + this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1) } - sendMessage(functionName, args) { + askReactNativeToInvokeInterfaceMethod(functionName, args) { const messageId = Math.random() window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId })) + return new Promise((resolve) => { this.pendingMessages.push({ messageId, @@ -98,6 +127,25 @@ export const MobileWebAppContainer = () => { const messageSender = new WebProcessMessageSender(); 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; ` @@ -107,14 +155,18 @@ export const MobileWebAppContainer = () => { const functionData = JSON.parse(message) void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args) } 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 returnValue = await (device as any)[functionName](...args) - console.log(`Native device function ${functionName} called`) - webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue })) + if (LoggingEnabled) { + console.log(`Native device function ${functionName} called`) + } + webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' })) } /* eslint-disable @typescript-eslint/no-empty-function */ diff --git a/packages/mobile/src/ModalStack.tsx b/packages/mobile/src/ModalStack.tsx index 573f81554..fb4875ab9 100644 --- a/packages/mobile/src/ModalStack.tsx +++ b/packages/mobile/src/ModalStack.tsx @@ -30,11 +30,11 @@ import React, { memo, useContext } from 'react' import { Platform } from 'react-native' import { HeaderButtons, Item } from 'react-navigation-header-buttons' import { ThemeContext } from 'styled-components' -import { HeaderTitleParams, TEnvironment } from './App' import { ApplicationContext } from './ApplicationContext' import { AppStackComponent } from './AppStack' import { HistoryStack } from './HistoryStack' import { MobileWebAppContainer } from './MobileWebAppContainer' +import { HeaderTitleParams, TEnvironment } from './NativeApp' export type ModalStackNavigatorParamList = { AppStack: undefined @@ -75,7 +75,31 @@ export type ModalStackNavigationProp() -export const MainStackComponent = ({ env }: { env: TEnvironment }) => { +export const MobileWebMainStackComponent = () => { + const MemoizedAppStackComponent = memo((props: ModalStackNavigationProp<'AppStack'>) => ( + + )) + + return ( + + + + ) +} + +export const NativeMainStackComponent = ({ env }: { env: TEnvironment }) => { const application = useContext(ApplicationContext) const theme = useContext(ThemeContext) diff --git a/packages/mobile/src/App.tsx b/packages/mobile/src/NativeApp.tsx similarity index 96% rename from packages/mobile/src/App.tsx rename to packages/mobile/src/NativeApp.tsx index ff519aeee..2950f810d 100644 --- a/packages/mobile/src/App.tsx +++ b/packages/mobile/src/NativeApp.tsx @@ -11,7 +11,7 @@ import { ThemeService, ThemeServiceContext } from '@Style/ThemeService' import React, { useCallback, useEffect, useRef, useState } from 'react' import { ThemeProvider } from 'styled-components/native' import { ApplicationContext } from './ApplicationContext' -import { MainStackComponent } from './ModalStack' +import { NativeMainStackComponent } from './ModalStack' export type HeaderTitleParams = { title?: string @@ -111,7 +111,7 @@ const AppComponent: React.FC<{ - + @@ -122,7 +122,7 @@ const AppComponent: React.FC<{ ) } -export const App = (props: { env: TEnvironment }) => { +export const NativeApp = (props: { env: TEnvironment }) => { const [application, setApplication] = useState() const createNewAppGroup = useCallback(() => { diff --git a/packages/mobile/src/Screens/Authenticate/Authenticate.tsx b/packages/mobile/src/Screens/Authenticate/Authenticate.tsx index c9e13065a..8393cf501 100644 --- a/packages/mobile/src/Screens/Authenticate/Authenticate.tsx +++ b/packages/mobile/src/Screens/Authenticate/Authenticate.tsx @@ -1,5 +1,5 @@ import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState' -import { MobileDeviceInterface } from '@Lib/Interface' +import { MobileDevice } from '@Lib/Interface' import { HeaderHeightContext } from '@react-navigation/elements' import { useFocusEffect } from '@react-navigation/native' import { ApplicationContext } from '@Root/ApplicationContext' @@ -164,7 +164,7 @@ export const Authenticate = ({ }, []) const checkForBiometrics = useCallback( - async () => (application?.deviceInterface as MobileDeviceInterface).getDeviceBiometricsAvailability(), + async () => (application?.deviceInterface as MobileDevice).getDeviceBiometricsAvailability(), [application], ) diff --git a/packages/mobile/src/Screens/Settings/Sections/SecuritySection.tsx b/packages/mobile/src/Screens/Settings/Sections/SecuritySection.tsx index 74d41a4d7..ce8f54607 100644 --- a/packages/mobile/src/Screens/Settings/Sections/SecuritySection.tsx +++ b/packages/mobile/src/Screens/Settings/Sections/SecuritySection.tsx @@ -1,4 +1,4 @@ -import { MobileDeviceInterface } from '@Lib/Interface' +import { MobileDevice } from '@Lib/Interface' import { useFocusEffect, useNavigation } from '@react-navigation/native' import { ApplicationContext } from '@Root/ApplicationContext' import { ButtonCell } from '@Root/Components/ButtonCell' @@ -53,7 +53,7 @@ export const SecuritySection = (props: Props) => { void getHasBiometrics() const hasBiometricsSupport = async () => { const hasBiometricsAvailable = await ( - application?.deviceInterface as MobileDeviceInterface + application?.deviceInterface as MobileDevice ).getDeviceBiometricsAvailability() if (mounted) { setSupportsBiometrics(hasBiometricsAvailable) diff --git a/packages/services/package.json b/packages/services/package.json index 86af7f295..89774e567 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -42,6 +42,7 @@ "eslint": "^8.23.1", "eslint-plugin-prettier": "*", "jest": "^28.1.2", - "ts-jest": "^28.0.5" + "ts-jest": "^28.0.5", + "typescript": "*" } } diff --git a/packages/services/src/Domain/Application/WebApplicationInterface.ts b/packages/services/src/Domain/Application/WebApplicationInterface.ts index 7d2512a72..6cc84394b 100644 --- a/packages/services/src/Domain/Application/WebApplicationInterface.ts +++ b/packages/services/src/Domain/Application/WebApplicationInterface.ts @@ -5,4 +5,8 @@ import { ApplicationInterface } from './ApplicationInterface' export interface WebApplicationInterface extends ApplicationInterface { notifyWebEvent(event: WebAppEvent, data?: unknown): void getDesktopService(): DesktopManagerInterface | undefined + handleMobileEnteringBackgroundEvent(): Promise + handleMobileGainingFocusEvent(): Promise + handleMobileLosingFocusEvent(): Promise + handleMobileResumingFromBackgroundEvent(): Promise } diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 87315064b..2ca8c5992 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -7,7 +7,7 @@ export interface MobileDeviceInterface extends DeviceInterface { getRawKeychainValue(): Promise getDeviceBiometricsAvailability(): Promise setAndroidScreenshotPrivacy(enable: boolean): Promise - getMobileScreenshotPrivacyEnabled(): Promise - setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise authenticateWithBiometrics(): Promise + hideMobileInterfaceFromScreenshots(): void + stopHidingMobileInterfaceFromScreenshots(): void } diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 27464192e..9f7cd573a 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -41,6 +41,7 @@ import { FileService, SubscriptionClientInterface, SubscriptionManager, + StorageValueModes, } from '@standardnotes/services' import { FilesClientInterface } from '@standardnotes/files' import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption' @@ -60,6 +61,7 @@ import { SNLog } from '../Log' import { Challenge, ChallengeResponse } from '../Services' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationOptionsDefaults } from './Options/Defaults' +import { MobileUnlockTiming } from '@Lib/Services/Protection/MobileUnlockTiming' /** How often to automatically sync, in milliseconds */ const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 @@ -927,7 +929,7 @@ export class SNApplication return this.deinit(this.getDeinitMode(), DeinitSource.Lock) } - async setBiometricsTiming(timing: InternalServices.MobileUnlockTiming) { + async setBiometricsTiming(timing: MobileUnlockTiming) { return this.protectionService.setBiometricsTiming(timing) } @@ -935,6 +937,18 @@ export class SNApplication return this.protectionService.getMobileScreenshotPrivacyEnabled() } + async getMobilePasscodeTiming(): Promise { + return this.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise< + MobileUnlockTiming | undefined + > + } + + async getMobileBiometricsTiming(): Promise { + return this.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise< + MobileUnlockTiming | undefined + > + } + async setMobileScreenshotPrivacyEnabled(isEnabled: boolean) { return this.protectionService.setMobileScreenshotPrivacyEnabled(isEnabled) } diff --git a/packages/snjs/lib/Client/ReactNativeToWebEvent.ts b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts new file mode 100644 index 000000000..c7be848ee --- /dev/null +++ b/packages/snjs/lib/Client/ReactNativeToWebEvent.ts @@ -0,0 +1,6 @@ +export enum ReactNativeToWebEvent { + EnteringBackground = 'EnteringBackground', + ResumingFromBackground = 'ResumingFromBackground', + GainingFocus = 'GainingFocus', + LosingFocus = 'LosingFocus', +} diff --git a/packages/snjs/lib/Client/index.ts b/packages/snjs/lib/Client/index.ts index aea7adb7a..0a9a03043 100644 --- a/packages/snjs/lib/Client/index.ts +++ b/packages/snjs/lib/Client/index.ts @@ -2,3 +2,4 @@ export * from './IconsController' export * from './NoteViewController' export * from './FileViewController' export * from './ItemGroupController' +export * from './ReactNativeToWebEvent' diff --git a/packages/snjs/lib/Services/Protection/MobileUnlockTiming.ts b/packages/snjs/lib/Services/Protection/MobileUnlockTiming.ts new file mode 100644 index 000000000..dfacd6ce6 --- /dev/null +++ b/packages/snjs/lib/Services/Protection/MobileUnlockTiming.ts @@ -0,0 +1,4 @@ +export enum MobileUnlockTiming { + Immediately = 'immediately', + OnQuit = 'on-quit', +} diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts index 840524c95..bc9ea1865 100644 --- a/packages/snjs/lib/Services/Protection/ProtectionService.ts +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -18,17 +18,13 @@ import { } from '@standardnotes/services' import { ProtectionsClientInterface } from './ClientInterface' import { ContentType } from '@standardnotes/common' +import { MobileUnlockTiming } from './MobileUnlockTiming' export enum ProtectionEvent { UnprotectedSessionBegan = 'UnprotectedSessionBegan', UnprotectedSessionExpired = 'UnprotectedSessionExpired', } -export enum MobileUnlockTiming { - Immediately = 'immediately', - OnQuit = 'on-quit', -} - export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30 export enum UnprotectedAccessSecondsDuration { diff --git a/packages/snjs/lib/Services/Protection/index.ts b/packages/snjs/lib/Services/Protection/index.ts index 7e2ea69fc..f0cb4a4fe 100644 --- a/packages/snjs/lib/Services/Protection/index.ts +++ b/packages/snjs/lib/Services/Protection/index.ts @@ -1,2 +1,3 @@ export * from './ClientInterface' export * from './ProtectionService' +export * from './MobileUnlockTiming' diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 6960c2479..f80b10309 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -17,12 +17,15 @@ import { DecryptedItemInterface, WebAppEvent, WebApplicationInterface, + MobileDeviceInterface, + MobileUnlockTiming, } from '@standardnotes/snjs' import { makeObservable, observable } from 'mobx' import { PanelResizedData } from '@/Types/PanelResizedData' import { isDesktopApplication } from '@/Utils' import { DesktopManager } from './Device/DesktopManager' import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services' +import { MobileWebReceiver } from './MobileWebReceiver' type WebServices = { viewControllerManager: ViewControllerManager @@ -41,6 +44,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter public itemControllerGroup: ItemGroupController public iconsController: IconsController private onVisibilityChange: () => void + private mobileWebReceiver?: MobileWebReceiver constructor( deviceInterface: WebOrDesktopDevice, @@ -70,6 +74,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.itemControllerGroup = new ItemGroupController(this) this.iconsController = new IconsController() + if (this.isNativeMobileWeb()) { + this.mobileWebReceiver = new MobileWebReceiver(this) + } + this.onVisibilityChange = () => { const visible = document.visibilityState === 'visible' const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur @@ -101,6 +109,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.itemControllerGroup.deinit() ;(this.itemControllerGroup as unknown) = undefined + ;(this.mobileWebReceiver as unknown) = undefined this.webEventObservers.length = 0 @@ -161,6 +170,13 @@ export class WebApplication extends SNApplication implements WebApplicationInter 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() { return this.webServices.themeService } @@ -203,4 +219,44 @@ export class WebApplication extends SNApplication implements WebApplicationInter const currentValue = this.isGlobalSpellcheckEnabled() return this.setPreference(PrefKey.EditorSpellcheck, !currentValue) } + + async handleMobileEnteringBackgroundEvent(): Promise { + await this.lockApplicationAfterMobileEventIfApplicable() + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + async handleMobileGainingFocusEvent(): Promise {} + + async handleMobileLosingFocusEvent(): Promise { + if (await this.getMobileScreenshotPrivacyEnabled()) { + this.mobileDevice.stopHidingMobileInterfaceFromScreenshots() + } + + await this.lockApplicationAfterMobileEventIfApplicable() + } + + async handleMobileResumingFromBackgroundEvent(): Promise { + if (await this.getMobileScreenshotPrivacyEnabled()) { + this.mobileDevice.hideMobileInterfaceFromScreenshots() + } + } + + private async lockApplicationAfterMobileEventIfApplicable(): Promise { + 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() + } + } } diff --git a/packages/web/src/javascripts/Application/MobileWebReceiver.ts b/packages/web/src/javascripts/Application/MobileWebReceiver.ts new file mode 100644 index 000000000..6f5b59c61 --- /dev/null +++ b/packages/web/src/javascripts/Application/MobileWebReceiver.ts @@ -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 + } + } +} diff --git a/yarn.lock b/yarn.lock index ac3f2f2d4..76541af24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7370,6 +7370,7 @@ __metadata: jest: ^28.1.2 reflect-metadata: ^0.1.13 ts-jest: ^28.0.5 + typescript: "*" languageName: unknown linkType: soft