Files
standardnotes-app-web/packages/mobile/src/Lib/ApplicationState.ts
2022-06-21 06:42:43 -05:00

684 lines
20 KiB
TypeScript

import { InternalEventBus } from '@standardnotes/services'
import {
ApplicationEvent,
ApplicationService,
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
ContentType,
isNullOrUndefined,
NoteViewController,
PayloadEmitSource,
PrefKey,
removeFromArray,
SmartView,
SNNote,
SNTag,
SNUserPrefs,
StorageKey,
StorageValueModes,
SystemViewId,
Uuid,
} from '@standardnotes/snjs'
import {
AppState,
AppStateStatus,
EmitterSubscription,
InteractionManager,
Keyboard,
KeyboardEventListener,
NativeEventSubscription,
NativeModules,
Platform,
} from 'react-native'
import FlagSecure from 'react-native-flag-secure-android'
import { hide, show } from 'react-native-privacy-snapshot'
import VersionInfo from 'react-native-version-info'
import pjson from '../../package.json'
import { MobileApplication } from './Application'
import { associateComponentWithNote } from './ComponentManager'
const { PlatformConstants } = NativeModules
export enum AppStateType {
LosingFocus = 1,
EnteringBackground = 2,
GainingFocus = 3,
ResumingFromBackground = 4,
TagChanged = 5,
EditorClosed = 6,
PreferencesChanged = 7,
}
export enum LockStateType {
Locked = 1,
Unlocked = 2,
}
export enum AppStateEventType {
KeyboardChangeEvent = 1,
TabletModeChange = 2,
DrawerOpen = 3,
}
export type TabletModeChangeData = {
new_isInTabletMode: boolean
old_isInTabletMode: boolean
}
export enum UnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}
export enum PasscodeKeyboardType {
Default = 'default',
Numeric = 'numeric',
}
export enum MobileStorageKey {
PasscodeKeyboardTypeKey = 'passcodeKeyboardType',
}
type EventObserverCallback = (event: AppStateEventType, data?: TabletModeChangeData) => void | Promise<void>
type ObserverCallback = (event: AppStateType, data?: unknown) => void | Promise<void>
type LockStateObserverCallback = (event: LockStateType) => void | Promise<void>
export class ApplicationState extends ApplicationService {
override application: MobileApplication
observers: ObserverCallback[] = []
private stateObservers: EventObserverCallback[] = []
private lockStateObservers: LockStateObserverCallback[] = []
locked = true
keyboardDidShowListener?: EmitterSubscription
keyboardDidHideListener?: EmitterSubscription
keyboardHeight?: number
removeAppEventObserver!: () => void
selectedTagRestored = false
selectedTag: SNTag | SmartView = this.application.items.getSmartViews()[0]
userPreferences?: SNUserPrefs
tabletMode = false
ignoreStateChanges = false
mostRecentState?: AppStateType
authenticationInProgress = false
multiEditorEnabled = false
screenshotPrivacyEnabled?: boolean
passcodeTiming?: UnlockTiming
biometricsTiming?: UnlockTiming
removeHandleReactNativeAppStateChangeListener: NativeEventSubscription
removeItemChangesListener?: () => void
removePreferencesLoadedListener?: () => void
constructor(application: MobileApplication) {
super(application, new InternalEventBus())
this.application = application
this.setTabletModeEnabled(this.isTabletDevice)
this.handleApplicationEvents()
this.handleItemsChanges()
this.removeHandleReactNativeAppStateChangeListener = AppState.addEventListener(
'change',
this.handleReactNativeAppStateChange,
)
this.keyboardDidShowListener = Keyboard.addListener('keyboardWillShow', this.keyboardDidShow)
this.keyboardDidHideListener = Keyboard.addListener('keyboardWillHide', this.keyboardDidHide)
}
override deinit() {
this.removeAppEventObserver()
;(this.removeAppEventObserver as unknown) = undefined
this.removeHandleReactNativeAppStateChangeListener.remove()
if (this.removeItemChangesListener) {
this.removeItemChangesListener()
}
if (this.removePreferencesLoadedListener) {
this.removePreferencesLoadedListener()
}
this.observers.length = 0
this.keyboardDidShowListener = undefined
this.keyboardDidHideListener = undefined
}
restoreSelectedTag() {
if (this.selectedTagRestored) {
return
}
const savedTagUuid: string | undefined = this.prefService.getValue(PrefKey.MobileSelectedTagUuid, undefined)
if (isNullOrUndefined(savedTagUuid)) {
this.selectedTagRestored = true
return
}
const savedTag =
(this.application.items.findItem(savedTagUuid) as SNTag) ||
this.application.items.getSmartViews().find((tag) => tag.uuid === savedTagUuid)
if (savedTag) {
this.setSelectedTag(savedTag, false)
this.selectedTagRestored = true
}
}
override async onAppStart() {
this.removePreferencesLoadedListener = this.prefService.addPreferencesLoadedObserver(() => {
this.notifyOfStateChange(AppStateType.PreferencesChanged)
})
await this.loadUnlockTiming()
}
override async onAppLaunch() {
MobileApplication.setPreviouslyLaunched()
this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true
void this.setAndroidScreenshotPrivacy(this.screenshotPrivacyEnabled)
}
/**
* Registers an observer for App State change
* @returns function that unregisters this observer
*/
public addStateChangeObserver(callback: ObserverCallback) {
this.observers.push(callback)
return () => {
removeFromArray(this.observers, callback)
}
}
/**
* Registers an observer for lock state change
* @returns function that unregisters this observer
*/
public addLockStateChangeObserver(callback: LockStateObserverCallback) {
this.lockStateObservers.push(callback)
return () => {
removeFromArray(this.lockStateObservers, callback)
}
}
/**
* Registers an observer for App State Event change
* @returns function that unregisters this observer
*/
public addStateEventObserver(callback: EventObserverCallback) {
this.stateObservers.push(callback)
return () => {
removeFromArray(this.stateObservers, callback)
}
}
/**
* Notify observers of ApplicationState change
*/
private notifyOfStateChange(state: AppStateType, data?: unknown) {
if (this.ignoreStateChanges) {
return
}
// Set most recent state before notifying observers, in case they need to query this value.
this.mostRecentState = state
for (const observer of this.observers) {
void observer(state, data)
}
}
/**
* Notify observers of ApplicationState Events
*/
private notifyEventObservers(event: AppStateEventType, data?: TabletModeChangeData) {
for (const observer of this.stateObservers) {
void observer(event, data)
}
}
/**
* Notify observers of ApplicationState Events
*/
private notifyLockStateObservers(event: LockStateType) {
for (const observer of this.lockStateObservers) {
void observer(event)
}
}
private async loadUnlockTiming() {
this.passcodeTiming = await this.getPasscodeTiming()
this.biometricsTiming = await this.getBiometricsTiming()
}
public async setAndroidScreenshotPrivacy(enable: boolean) {
if (Platform.OS === 'android') {
enable ? FlagSecure.activate() : FlagSecure.deactivate()
}
}
/**
* Creates a new editor if one doesn't exist. If one does, we'll replace the
* editor's note with an empty one.
*/
async createEditor(title?: string) {
const selectedTagUuid = this.selectedTag
? this.selectedTag instanceof SmartView
? undefined
: this.selectedTag.uuid
: undefined
this.application.editorGroup.closeActiveItemController()
const noteView = await this.application.editorGroup.createItemController({ title, tag: selectedTagUuid })
const defaultEditor = this.application.componentManager.getDefaultEditor()
if (defaultEditor) {
await associateComponentWithNote(this.application, defaultEditor, this.getActiveNoteController().item)
}
return noteView
}
async openEditor(noteUuid: string): Promise<NoteViewController> {
const note = this.application.items.findItem(noteUuid) as SNNote
const activeEditor = this.getActiveNoteController()
if (activeEditor) {
this.application.editorGroup.closeActiveItemController()
}
const noteView = (await this.application.editorGroup.createItemController(note)) as NoteViewController
if (note && note.conflictOf) {
void InteractionManager.runAfterInteractions(() => {
void this.application?.mutator.changeAndSaveItem(note, (mutator) => {
mutator.conflictOf = undefined
})
})
}
return noteView
}
getActiveNoteController(): NoteViewController {
return this.application.editorGroup.itemControllers[0] as NoteViewController
}
getEditors(): NoteViewController[] {
return this.application.editorGroup.itemControllers as NoteViewController[]
}
closeEditor(editor: NoteViewController) {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeItemController(editor)
}
closeActiveEditor() {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeActiveItemController()
}
closeAllEditors() {
this.notifyOfStateChange(AppStateType.EditorClosed)
this.application.editorGroup.closeAllItemControllers()
}
editorForNote(uuid: Uuid): NoteViewController | void {
for (const editor of this.getEditors()) {
if (editor.item?.uuid === uuid) {
return editor
}
}
}
private keyboardDidShow: KeyboardEventListener = (e) => {
this.keyboardHeight = e.endCoordinates.height
this.notifyEventObservers(AppStateEventType.KeyboardChangeEvent)
}
private keyboardDidHide: KeyboardEventListener = () => {
this.keyboardHeight = 0
this.notifyEventObservers(AppStateEventType.KeyboardChangeEvent)
}
/**
* @returns Returns keybord height
*/
getKeyboardHeight() {
return this.keyboardHeight
}
/**
* Reacts to @SNNote and @SNTag Changes
*/
private handleItemsChanges() {
this.removeItemChangesListener = this.application.streamItems<SNNote | SNTag>(
[ContentType.Note, ContentType.Tag],
async ({ changed, inserted, removed, source }) => {
if (source === PayloadEmitSource.PreSyncSave || source === PayloadEmitSource.RemoteRetrieved) {
const removedNotes = removed.filter((i) => i.content_type === ContentType.Note)
for (const removedNote of removedNotes) {
const editor = this.editorForNote(removedNote.uuid)
if (editor) {
this.closeEditor(editor)
}
}
const notes = [...changed, ...inserted].filter((candidate) => candidate.content_type === ContentType.Note)
const isBrowswingTrashedNotes =
this.selectedTag instanceof SmartView && this.selectedTag.uuid === SystemViewId.TrashedNotes
const isBrowsingArchivedNotes =
this.selectedTag instanceof SmartView && this.selectedTag.uuid === SystemViewId.ArchivedNotes
for (const note of notes) {
const editor = this.editorForNote(note.uuid)
if (!editor) {
continue
}
if (note.trashed && !isBrowswingTrashedNotes) {
this.closeEditor(editor)
} else if (note.archived && !isBrowsingArchivedNotes) {
this.closeEditor(editor)
}
}
}
if (this.selectedTag) {
const matchingTag = [...changed, ...inserted].find((candidate) => candidate.uuid === this.selectedTag.uuid)
if (matchingTag) {
this.selectedTag = matchingTag as SNTag
}
}
},
)
}
/**
* Registers for MobileApplication events
*/
private handleApplicationEvents() {
this.removeAppEventObserver = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded: {
this.restoreSelectedTag()
break
}
case ApplicationEvent.Started: {
this.locked = true
break
}
case ApplicationEvent.Launched: {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
break
}
}
})
}
/**
* Set selected @SNTag
*/
public setSelectedTag(tag: SNTag | SmartView, saveSelection = true) {
if (this.selectedTag.uuid === tag.uuid) {
return
}
const previousTag = this.selectedTag
this.selectedTag = tag
if (saveSelection) {
void this.application.getLocalPreferences().setUserPrefValue(PrefKey.MobileSelectedTagUuid, tag.uuid)
}
this.notifyOfStateChange(AppStateType.TagChanged, {
tag,
previousTag,
})
}
/**
* @returns tags that are referencing note
*/
public getNoteTags(note: SNNote) {
return this.application.items.itemsReferencingItem(note).filter((ref) => {
return ref.content_type === ContentType.Tag
}) as SNTag[]
}
/**
* @returns notes this tag references
*/
public getTagNotes(tag: SNTag | SmartView) {
if (tag instanceof SmartView) {
return this.application.items.notesMatchingSmartView(tag)
} else {
return this.application.items.referencesForItem(tag).filter((ref) => {
return ref.content_type === ContentType.Note
}) as SNNote[]
}
}
public getSelectedTag() {
return this.selectedTag
}
static get version() {
return `${pjson.version} (${VersionInfo.buildVersion})`
}
get isTabletDevice() {
const deviceType = PlatformConstants.interfaceIdiom
return deviceType === 'pad'
}
get isInTabletMode() {
return this.tabletMode
}
setTabletModeEnabled(enabledTabletMode: boolean) {
if (enabledTabletMode !== this.tabletMode) {
this.tabletMode = enabledTabletMode
this.notifyEventObservers(AppStateEventType.TabletModeChange, {
new_isInTabletMode: enabledTabletMode,
old_isInTabletMode: !enabledTabletMode,
})
}
}
getPasscodeTimingOptions() {
return [
{
title: 'Immediately',
key: UnlockTiming.Immediately,
selected: this.passcodeTiming === UnlockTiming.Immediately,
},
{
title: 'On Quit',
key: UnlockTiming.OnQuit,
selected: this.passcodeTiming === UnlockTiming.OnQuit,
},
]
}
getBiometricsTimingOptions() {
return [
{
title: 'Immediately',
key: UnlockTiming.Immediately,
selected: this.biometricsTiming === UnlockTiming.Immediately,
},
{
title: 'On Quit',
key: UnlockTiming.OnQuit,
selected: this.biometricsTiming === UnlockTiming.OnQuit,
},
]
}
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 === UnlockTiming.Immediately) {
await this.application.lock()
} else if (hasBiometrics && this.biometricsTiming === UnlockTiming.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)
},
})
}
}
}
/**
* handles App State change from React Native
*/
private handleReactNativeAppStateChange = async (nextAppState: AppStateStatus) => {
if (this.ignoreStateChanges) {
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 === AppStateType.EnteringBackground
const isEnteringBackground = nextAppState === 'background'
const isLosingFocus = nextAppState === 'inactive'
if (isEnteringBackground) {
this.notifyOfStateChange(AppStateType.EnteringBackground)
return this.checkAndLockApplication()
}
if (isResumingFromBackground || isResuming) {
if (this.screenshotPrivacyEnabled) {
hide()
}
if (isResumingFromBackground) {
this.notifyOfStateChange(AppStateType.ResumingFromBackground)
}
// Notify of GainingFocus even if resuming from background
this.notifyOfStateChange(AppStateType.GainingFocus)
return
}
if (isLosingFocus) {
if (this.screenshotPrivacyEnabled) {
show()
}
this.notifyOfStateChange(AppStateType.LosingFocus)
return this.checkAndLockApplication()
}
}
/**
* Visibility change events are like active, inactive, background,
* while non-app cycle events are custom events like locking and unlocking
*/
isAppVisibilityChange(state: AppStateType) {
return (
[
AppStateType.LosingFocus,
AppStateType.EnteringBackground,
AppStateType.GainingFocus,
AppStateType.ResumingFromBackground,
] as Array<AppStateType>
).includes(state)
}
private async getScreenshotPrivacyEnabled(): Promise<boolean | undefined> {
return this.application.getValue(StorageKey.MobileScreenshotPrivacyEnabled, StorageValueModes.Default) as Promise<
boolean | undefined
>
}
private async getPasscodeTiming(): Promise<UnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise<
UnlockTiming | undefined
>
}
private async getBiometricsTiming(): Promise<UnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise<
UnlockTiming | undefined
>
}
public async setScreenshotPrivacyEnabled(enabled: boolean) {
await this.application.setValue(StorageKey.MobileScreenshotPrivacyEnabled, enabled, StorageValueModes.Default)
this.screenshotPrivacyEnabled = enabled
void this.setAndroidScreenshotPrivacy(enabled)
}
public async setPasscodeTiming(timing: UnlockTiming) {
await this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.passcodeTiming = timing
}
public async setBiometricsTiming(timing: UnlockTiming) {
await this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.biometricsTiming = timing
}
public async getPasscodeKeyboardType(): Promise<PasscodeKeyboardType> {
return this.application.getValue(
MobileStorageKey.PasscodeKeyboardTypeKey,
StorageValueModes.Nonwrapped,
) as Promise<PasscodeKeyboardType>
}
public async setPasscodeKeyboardType(type: PasscodeKeyboardType) {
await this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped)
}
public onDrawerOpen() {
this.notifyEventObservers(AppStateEventType.DrawerOpen)
}
/*
Allows other parts of the code to perform external actions without triggering state change notifications.
This is useful on Android when you present a share sheet and dont want immediate authentication to appear.
*/
async performActionWithoutStateChangeImpact(block: () => void | Promise<void>, notAwaited?: boolean) {
this.ignoreStateChanges = true
if (notAwaited) {
void block()
} else {
await block()
}
setTimeout(() => {
this.ignoreStateChanges = false
}, 350)
}
getMostRecentState() {
return this.mostRecentState
}
private get prefService() {
return this.application.getLocalPreferences()
}
public getEnvironment() {
const bundleId = VersionInfo.bundleIdentifier
return bundleId && bundleId.includes('dev') ? 'dev' : 'prod'
}
}