feat: mobile web bridge (#1597)
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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={() => ({
|
||||||
|
|||||||
62
packages/mobile/src/AppStateObserverService.ts
Normal file
62
packages/mobile/src/AppStateObserverService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
16
packages/mobile/src/MobileWebApp.tsx
Normal file
16
packages/mobile/src/MobileWebApp.tsx
Normal 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 />
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
6
packages/snjs/lib/Client/ReactNativeToWebEvent.ts
Normal file
6
packages/snjs/lib/Client/ReactNativeToWebEvent.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum ReactNativeToWebEvent {
|
||||||
|
EnteringBackground = 'EnteringBackground',
|
||||||
|
ResumingFromBackground = 'ResumingFromBackground',
|
||||||
|
GainingFocus = 'GainingFocus',
|
||||||
|
LosingFocus = 'LosingFocus',
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum MobileUnlockTiming {
|
||||||
|
Immediately = 'immediately',
|
||||||
|
OnQuit = 'on-quit',
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './ClientInterface'
|
export * from './ClientInterface'
|
||||||
export * from './ProtectionService'
|
export * from './ProtectionService'
|
||||||
|
export * from './MobileUnlockTiming'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user