Files
standardnotes-app-web/packages/mobile/src/Lib/Interface.ts

645 lines
18 KiB
TypeScript

import AsyncStorage from '@react-native-community/async-storage'
import SNReactNative from '@standardnotes/react-native-utils'
import { AppleIAPReceipt } from '@standardnotes/services'
import {
AppleIAPProductId,
ApplicationIdentifier,
Environment,
LegacyMobileKeychainStructure,
LegacyRawKeychainValue,
MobileDeviceInterface,
NamespacedRootKeyInKeychain,
Platform as SNPlatform,
RawKeychainValue,
removeFromArray,
TransferPayload,
UuidString,
} from '@standardnotes/snjs'
import { ColorSchemeObserverService } from 'ColorSchemeObserverService'
import {
Alert,
Appearance,
AppState,
AppStateStatus,
ColorSchemeName,
Linking,
PermissionsAndroid,
Platform,
StatusBar,
} from 'react-native'
import FileViewer from 'react-native-file-viewer'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import FlagSecure from 'react-native-flag-secure-android'
import {
CachesDirectoryPath,
DocumentDirectoryPath,
DownloadDirectoryPath,
exists,
unlink,
writeFile,
} from 'react-native-fs'
import { hide, show } from 'react-native-privacy-snapshot'
import Share from 'react-native-share'
import { AndroidBackHandlerService } from '../AndroidBackHandlerService'
import { PurchaseManager } from '../PurchaseManager'
import { AppStateObserverService } from './../AppStateObserverService'
import Keychain from './Keychain'
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.
*/
const LEGACY_IDENTIFIER = 'standardnotes'
/**
* We use this function to decide if we need to prefix the identifier in getDatabaseKeyPrefix or not.
* It is also used to decide if the raw or the namespaced keychain is used.
* @param identifier The ApplicationIdentifier
*/
const isLegacyIdentifier = function (identifier: ApplicationIdentifier) {
return identifier && identifier === LEGACY_IDENTIFIER
}
function isLegacyMobileKeychain(
x: LegacyMobileKeychainStructure | RawKeychainValue,
): x is LegacyMobileKeychainStructure {
return x.ak != undefined
}
const showLoadFailForItemIds = (failedItemIds: string[]) => {
let text =
'The following items could not be loaded. This may happen if you are in low-memory conditions, or if the note is very large in size. We recommend breaking up large notes into smaller chunks using the desktop or web app.\n\nItems:\n'
let index = 0
text += failedItemIds.map((id) => {
let result = id
if (index !== failedItemIds.length - 1) {
result += '\n'
}
index++
return result
})
Alert.alert('Unable to load item(s)', text)
}
export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
private eventObservers: MobileDeviceEventHandler[] = []
public isDarkMode = false
public statusBarBgColor: string | undefined
private componentUrls: Map<UuidString, string> = new Map()
constructor(
private stateObserverService?: AppStateObserverService,
private androidBackHandlerService?: AndroidBackHandlerService,
private colorSchemeService?: ColorSchemeObserverService,
) {}
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined> {
return PurchaseManager.getInstance().purchase(plan)
}
deinit() {
this.stateObserverService?.deinit()
;(this.stateObserverService as unknown) = undefined
this.androidBackHandlerService?.deinit()
;(this.androidBackHandlerService as unknown) = undefined
this.colorSchemeService?.deinit()
;(this.colorSchemeService as unknown) = undefined
}
consoleLog(...args: unknown[]): void {
// eslint-disable-next-line no-console
console.log(args)
}
async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
await Keychain.setKeys(value)
}
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
const value = await this.getRawStorageValue(key)
if (value == undefined) {
return undefined
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
private getDatabaseKeyPrefix(identifier: ApplicationIdentifier) {
if (identifier && !isLegacyIdentifier(identifier)) {
return `${identifier}-Item-`
} else {
return 'Item-'
}
}
private keyForPayloadId(id: string, identifier: ApplicationIdentifier) {
return `${this.getDatabaseKeyPrefix(identifier)}${id}`
}
private async getAllDatabaseKeys(identifier: ApplicationIdentifier) {
const keys = await AsyncStorage.getAllKeys()
const filtered = keys.filter((key) => {
return key.startsWith(this.getDatabaseKeyPrefix(identifier))
})
return filtered
}
getDatabaseKeys(): Promise<string[]> {
return AsyncStorage.getAllKeys()
}
private async getRawStorageKeyValues(keys: string[]) {
const results: { key: string; value: unknown }[] = []
if (Platform.OS === 'android') {
for (const key of keys) {
try {
const item = await AsyncStorage.getItem(key)
if (item) {
results.push({ key, value: item })
}
} catch (e) {
console.error('Error getting item', key, e)
}
}
} else {
try {
for (const item of await AsyncStorage.multiGet(keys)) {
if (item[1]) {
results.push({ key: item[0], value: item[1] })
}
}
} catch (e) {
console.error('Error getting items', e)
}
}
return results
}
private async getDatabaseKeyValues(keys: string[]) {
const results: (TransferPayload | unknown)[] = []
if (Platform.OS === 'android') {
const failedItemIds: string[] = []
for (const key of keys) {
try {
const item = await AsyncStorage.getItem(key)
if (item) {
try {
results.push(JSON.parse(item) as TransferPayload)
} catch (e) {
results.push(item)
}
}
} catch (e) {
console.error('Error getting item', key, e)
failedItemIds.push(key)
}
}
if (failedItemIds.length > 0) {
showLoadFailForItemIds(failedItemIds)
}
} else {
try {
for (const item of await AsyncStorage.multiGet(keys)) {
if (item[1]) {
try {
results.push(JSON.parse(item[1]))
} catch (e) {
results.push(item[1])
}
}
}
} catch (e) {
console.error('Error getting items', e)
}
}
return results
}
async getRawStorageValue(key: string) {
const item = await AsyncStorage.getItem(key)
if (item) {
try {
return JSON.parse(item)
} catch (e) {
return item
}
}
}
hideMobileInterfaceFromScreenshots(): void {
hide()
this.setAndroidScreenshotPrivacy(true)
}
stopHidingMobileInterfaceFromScreenshots(): void {
show()
this.setAndroidScreenshotPrivacy(false)
}
async getAllRawStorageKeyValues() {
const keys = await AsyncStorage.getAllKeys()
return this.getRawStorageKeyValues(keys)
}
setRawStorageValue(key: string, value: string): Promise<void> {
return AsyncStorage.setItem(key, JSON.stringify(value))
}
removeRawStorageValue(key: string): Promise<void> {
return AsyncStorage.removeItem(key)
}
removeAllRawStorageValues(): Promise<void> {
return AsyncStorage.clear()
}
openDatabase(): Promise<{ isNewDatabase?: boolean | undefined } | undefined> {
return Promise.resolve({ isNewDatabase: false })
}
async getAllRawDatabasePayloads<T extends TransferPayload = TransferPayload>(
identifier: ApplicationIdentifier,
): Promise<T[]> {
const keys = await this.getAllDatabaseKeys(identifier)
return this.getDatabaseKeyValues(keys) as Promise<T[]>
}
saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier): Promise<void> {
return this.saveRawDatabasePayloads([payload], identifier)
}
async saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier): Promise<void> {
if (payloads.length === 0) {
return
}
await Promise.all(
payloads.map((item) => {
return AsyncStorage.setItem(this.keyForPayloadId(item.uuid, identifier), JSON.stringify(item))
}),
)
}
removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier): Promise<void> {
return this.removeRawStorageValue(this.keyForPayloadId(id, identifier))
}
async removeAllRawDatabasePayloads(identifier: ApplicationIdentifier): Promise<void> {
const keys = await this.getAllDatabaseKeys(identifier)
return AsyncStorage.multiRemove(keys)
}
async getNamespacedKeychainValue(
identifier: ApplicationIdentifier,
): Promise<NamespacedRootKeyInKeychain | undefined> {
const keychain = await this.getRawKeychainValue()
if (!keychain) {
return
}
const namespacedValue = keychain[identifier]
if (!namespacedValue && isLegacyIdentifier(identifier)) {
return keychain as unknown as NamespacedRootKeyInKeychain
}
return namespacedValue
}
async setNamespacedKeychainValue(
value: NamespacedRootKeyInKeychain,
identifier: ApplicationIdentifier,
): Promise<void> {
let keychain = await this.getRawKeychainValue()
if (!keychain) {
keychain = {}
}
await Keychain.setKeys({
...keychain,
[identifier]: value,
})
}
async clearNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<void> {
const keychain = await this.getRawKeychainValue()
if (!keychain) {
return
}
if (!keychain[identifier] && isLegacyIdentifier(identifier) && isLegacyMobileKeychain(keychain)) {
await this.clearRawKeychainValue()
return
}
delete keychain[identifier]
await Keychain.setKeys(keychain)
}
async getDeviceBiometricsAvailability() {
try {
await FingerprintScanner.isSensorAvailable()
return true
} catch (e) {
return false
}
}
async authenticateWithBiometrics() {
this.stateObserverService?.beginIgnoringStateChanges()
const result = await new Promise<boolean>((resolve) => {
if (Platform.OS === 'android') {
FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts type does not exist for deviceCredentialAllowed
deviceCredentialAllowed: true,
description: 'Biometrics are required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error) => {
FingerprintScanner.release()
if (error.name === 'DeviceLocked') {
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
resolve(false)
})
} else {
// iOS
FingerprintScanner.authenticate({
fallbackEnabled: true,
description: 'This is required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error_1) => {
FingerprintScanner.release()
if (error_1.name !== 'SystemCancel') {
if (error_1.name !== 'UserCancel') {
Alert.alert('Unsuccessful')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
}
resolve(false)
})
}
})
this.stateObserverService?.stopIgnoringStateChanges()
return result
}
async getRawKeychainValue(): Promise<RawKeychainValue | undefined> {
const result = await Keychain.getKeys()
if (result === null) {
return undefined
}
return result
}
async clearRawKeychainValue(): Promise<void> {
await Keychain.clearKeys()
}
setAndroidScreenshotPrivacy(enable: boolean): void {
if (Platform.OS === 'android') {
enable ? FlagSecure.activate() : FlagSecure.deactivate()
}
}
openUrl(url: string) {
const showAlert = () => {
Alert.alert('Unable to Open', `Unable to open URL ${url}.`)
}
Linking.canOpenURL(url)
.then((supported) => {
if (!supported) {
showAlert()
return
} else {
return Linking.openURL(url)
}
})
.catch(() => showAlert())
}
async clearAllDataFromDevice(_workspaceIdentifiers: string[]): Promise<{ killsApplication: boolean }> {
await this.removeAllRawStorageValues()
await this.clearRawKeychainValue()
return { killsApplication: false }
}
performSoftReset() {
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
}
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.eventObservers.push(handler)
const thislessObservers = this.eventObservers
return () => {
removeFromArray(thislessObservers, handler)
}
}
handleThemeSchemeChange(isDark: boolean, bgColor: string): void {
this.isDarkMode = isDark
this.statusBarBgColor = bgColor
this.reloadStatusBarStyle()
}
reloadStatusBarStyle(animated = true) {
if (this.statusBarBgColor && Platform.OS === 'android') {
StatusBar.setBackgroundColor(this.statusBarBgColor, animated)
}
StatusBar.setBarStyle(this.isDarkMode ? 'light-content' : 'dark-content', animated)
}
private notifyEvent(event: MobileDeviceEvent): void {
for (const handler of this.eventObservers) {
handler(event)
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
performHardReset() {}
isDeviceDestroyed() {
return false
}
async deleteFileAtPathIfExists(path: string) {
if (await exists(path)) {
await unlink(path)
}
}
async shareBase64AsFile(base64: string, filename: string) {
let downloadedTempFilePath: string | undefined
try {
downloadedTempFilePath = await this.downloadBase64AsFile(base64, filename, true)
if (!downloadedTempFilePath) {
return
}
await Share.open({
url: `file://${downloadedTempFilePath}`,
failOnCancel: false,
})
} catch (error) {
this.consoleLog(`${error}`)
} finally {
if (downloadedTempFilePath) {
void this.deleteFileAtPathIfExists(downloadedTempFilePath)
}
}
}
getFileDestinationPath(filename: string, saveInTempLocation: boolean): string {
let directory = DocumentDirectoryPath
if (Platform.OS === 'android') {
directory = saveInTempLocation ? CachesDirectoryPath : DownloadDirectoryPath
}
return `${directory}/${filename}`
}
async hasStoragePermissionOnAndroid(): Promise<boolean> {
if (Platform.OS !== 'android') {
return true
}
const grantedStatus = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
if (grantedStatus === PermissionsAndroid.RESULTS.GRANTED) {
return true
}
Alert.alert(
'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.',
)
return false
}
async downloadBase64AsFile(
base64: string,
filename: string,
saveInTempLocation = false,
): Promise<string | undefined> {
const isGrantedStoragePermissionOnAndroid = await this.hasStoragePermissionOnAndroid()
if (!isGrantedStoragePermissionOnAndroid) {
return
}
try {
const path = this.getFileDestinationPath(filename, saveInTempLocation)
await this.deleteFileAtPathIfExists(path)
await writeFile(path, base64.replace(/data.*base64,/, ''), 'base64')
return path
} catch (error) {
this.consoleLog(`${error}`)
}
}
async previewFile(base64: string, filename: string): Promise<boolean> {
const tempLocation = await this.downloadBase64AsFile(base64, filename, true)
if (!tempLocation) {
this.consoleLog('Error: Could not download file to preview')
return false
}
try {
await FileViewer.open(tempLocation, {
onDismiss: async () => {
await this.deleteFileAtPathIfExists(tempLocation)
},
})
} catch (error) {
this.consoleLog(error)
return false
}
return true
}
exitApp(shouldConfirm?: boolean) {
if (!shouldConfirm) {
SNReactNative.exitApp()
return
}
Alert.alert(
'Close app',
'Do you want to close the app?',
[
{
text: 'Cancel',
style: 'cancel',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onPress: async () => {},
},
{
text: 'Close',
style: 'destructive',
onPress: async () => {
SNReactNative.exitApp()
},
},
],
{
cancelable: true,
},
)
}
addComponentUrl(componentUuid: UuidString, componentUrl: string) {
this.componentUrls.set(componentUuid, componentUrl)
}
removeComponentUrl(componentUuid: UuidString) {
this.componentUrls.delete(componentUuid)
}
isUrlComponentUrl(url: string): boolean {
return Array.from(this.componentUrls.values()).includes(url)
}
async getAppState(): Promise<AppStateStatus> {
return AppState.currentState
}
async getColorScheme(): Promise<ColorSchemeName> {
return Appearance.getColorScheme()
}
}