feat: mobile app package (#1075)
This commit is contained in:
62
packages/mobile/src/Lib/AlertService.ts
Normal file
62
packages/mobile/src/Lib/AlertService.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { MODAL_BLOCKING_ALERT } from '@Root/Screens/screens'
|
||||
import { AlertService, ButtonType, DismissBlockingDialog } from '@standardnotes/snjs'
|
||||
import { Alert, AlertButton } from 'react-native'
|
||||
import { goBack, navigate } from './NavigationService'
|
||||
|
||||
export class MobileAlertService extends AlertService {
|
||||
blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise<DismissBlockingDialog> {
|
||||
navigate(MODAL_BLOCKING_ALERT, { text, title })
|
||||
|
||||
return goBack
|
||||
}
|
||||
alert(text: string, title: string, closeButtonText?: string) {
|
||||
return new Promise<void>(resolve => {
|
||||
// On iOS, confirm should go first. On Android, cancel should go first.
|
||||
const buttons = [
|
||||
{
|
||||
text: closeButtonText,
|
||||
onPress: async () => {
|
||||
resolve()
|
||||
},
|
||||
},
|
||||
]
|
||||
Alert.alert(title, text, buttons, {
|
||||
cancelable: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
confirm(
|
||||
text: string,
|
||||
title: string,
|
||||
confirmButtonText = 'Confirm',
|
||||
confirmButtonType?: ButtonType,
|
||||
cancelButtonText = 'Cancel',
|
||||
) {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
// On iOS, confirm should go first. On Android, cancel should go first.
|
||||
const buttons: AlertButton[] = [
|
||||
{
|
||||
text: cancelButtonText,
|
||||
style: 'cancel',
|
||||
onPress: async () => {
|
||||
resolve(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: confirmButtonText,
|
||||
style: confirmButtonType === ButtonType.Danger ? 'destructive' : 'default',
|
||||
onPress: async () => {
|
||||
resolve(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
Alert.alert(title, text, buttons, {
|
||||
cancelable: true,
|
||||
onDismiss: async () => {
|
||||
reject()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
164
packages/mobile/src/Lib/Application.ts
Normal file
164
packages/mobile/src/Lib/Application.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { SCREEN_AUTHENTICATE } from '@Root/Screens/screens'
|
||||
import {
|
||||
Challenge,
|
||||
ChallengePrompt,
|
||||
ChallengeReason,
|
||||
ChallengeValidation,
|
||||
DeinitMode,
|
||||
DeinitSource,
|
||||
Environment,
|
||||
IconsController,
|
||||
NoteGroupController,
|
||||
platformFromString,
|
||||
SNApplication,
|
||||
SNComponentManager,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Platform } from 'react-native'
|
||||
import VersionInfo from 'react-native-version-info'
|
||||
import { version } from '../../package.json'
|
||||
import { MobileAlertService } from './AlertService'
|
||||
import { ApplicationState, UnlockTiming } from './ApplicationState'
|
||||
import { BackupsService } from './BackupsService'
|
||||
import { ComponentManager } from './ComponentManager'
|
||||
import { FilesService } from './FilesService'
|
||||
import { InstallationService } from './InstallationService'
|
||||
import { MobileDeviceInterface } from './Interface'
|
||||
import { push } from './NavigationService'
|
||||
import { PreferencesManager } from './PreferencesManager'
|
||||
import { SNReactNativeCrypto } from './ReactNativeCrypto'
|
||||
import { ReviewService } from './ReviewService'
|
||||
import { StatusManager } from './StatusManager'
|
||||
|
||||
type MobileServices = {
|
||||
applicationState: ApplicationState
|
||||
reviewService: ReviewService
|
||||
backupsService: BackupsService
|
||||
installationService: InstallationService
|
||||
prefsService: PreferencesManager
|
||||
statusManager: StatusManager
|
||||
filesService: FilesService
|
||||
}
|
||||
|
||||
const IsDev = VersionInfo.bundleIdentifier?.includes('dev')
|
||||
|
||||
export class MobileApplication extends SNApplication {
|
||||
private MobileServices!: MobileServices
|
||||
public editorGroup: NoteGroupController
|
||||
public iconsController: IconsController
|
||||
private startedDeinit = false
|
||||
|
||||
// UI remounts when Uuid changes
|
||||
public Uuid: string
|
||||
|
||||
static previouslyLaunched = false
|
||||
|
||||
constructor(deviceInterface: MobileDeviceInterface, identifier: string) {
|
||||
super({
|
||||
environment: Environment.Mobile,
|
||||
platform: platformFromString(Platform.OS),
|
||||
deviceInterface: deviceInterface,
|
||||
crypto: new SNReactNativeCrypto(),
|
||||
alertService: new MobileAlertService(),
|
||||
identifier,
|
||||
swapClasses: [
|
||||
{
|
||||
swap: SNComponentManager,
|
||||
with: ComponentManager,
|
||||
},
|
||||
],
|
||||
defaultHost: IsDev ? 'https://api-dev.standardnotes.com' : 'https://api.standardnotes.com',
|
||||
appVersion: version,
|
||||
webSocketUrl: IsDev ? 'wss://sockets-dev.standardnotes.com' : 'wss://sockets.standardnotes.com',
|
||||
})
|
||||
|
||||
this.Uuid = Math.random().toString()
|
||||
this.editorGroup = new NoteGroupController(this)
|
||||
this.iconsController = new IconsController()
|
||||
|
||||
void this.mobileComponentManager.initialize(this.protocolService)
|
||||
}
|
||||
|
||||
get mobileComponentManager(): ComponentManager {
|
||||
return this.componentManager as ComponentManager
|
||||
}
|
||||
|
||||
static getPreviouslyLaunched() {
|
||||
return this.previouslyLaunched
|
||||
}
|
||||
|
||||
static setPreviouslyLaunched() {
|
||||
this.previouslyLaunched = true
|
||||
}
|
||||
|
||||
public hasStartedDeinit() {
|
||||
return this.startedDeinit
|
||||
}
|
||||
|
||||
override deinit(mode: DeinitMode, source: DeinitSource): void {
|
||||
this.startedDeinit = true
|
||||
|
||||
for (const service of Object.values(this.MobileServices)) {
|
||||
if (service.deinit) {
|
||||
service.deinit()
|
||||
}
|
||||
|
||||
if ('application' in service) {
|
||||
const typedService = service as { application?: MobileApplication }
|
||||
typedService.application = undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.MobileServices = {} as MobileServices
|
||||
this.editorGroup.deinit()
|
||||
super.deinit(mode, source)
|
||||
}
|
||||
|
||||
override getLaunchChallenge() {
|
||||
const challenge = super.getLaunchChallenge()
|
||||
|
||||
if (!challenge) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const previouslyLaunched = MobileApplication.getPreviouslyLaunched()
|
||||
const biometricsTiming = this.getAppState().biometricsTiming
|
||||
|
||||
if (previouslyLaunched && biometricsTiming === UnlockTiming.OnQuit) {
|
||||
const filteredPrompts = challenge.prompts.filter(
|
||||
(prompt: ChallengePrompt) => prompt.validation !== ChallengeValidation.Biometric,
|
||||
)
|
||||
|
||||
return new Challenge(filteredPrompts, ChallengeReason.ApplicationUnlock, false)
|
||||
}
|
||||
|
||||
return challenge
|
||||
}
|
||||
|
||||
promptForChallenge(challenge: Challenge) {
|
||||
push(SCREEN_AUTHENTICATE, { challenge, title: challenge.modalTitle })
|
||||
}
|
||||
|
||||
setMobileServices(services: MobileServices) {
|
||||
this.MobileServices = services
|
||||
}
|
||||
|
||||
public getAppState() {
|
||||
return this.MobileServices.applicationState
|
||||
}
|
||||
|
||||
public getBackupsService() {
|
||||
return this.MobileServices.backupsService
|
||||
}
|
||||
|
||||
public getLocalPreferences() {
|
||||
return this.MobileServices.prefsService
|
||||
}
|
||||
|
||||
public getStatusManager() {
|
||||
return this.MobileServices.statusManager
|
||||
}
|
||||
|
||||
public getFilesService() {
|
||||
return this.MobileServices.filesService
|
||||
}
|
||||
}
|
||||
45
packages/mobile/src/Lib/ApplicationGroup.ts
Normal file
45
packages/mobile/src/Lib/ApplicationGroup.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { InternalEventBus } from '@standardnotes/services'
|
||||
import { ApplicationDescriptor, DeviceInterface, SNApplicationGroup } from '@standardnotes/snjs'
|
||||
import { MobileApplication } from './Application'
|
||||
import { ApplicationState } from './ApplicationState'
|
||||
import { BackupsService } from './BackupsService'
|
||||
import { FilesService } from './FilesService'
|
||||
import { InstallationService } from './InstallationService'
|
||||
import { MobileDeviceInterface } from './Interface'
|
||||
import { PreferencesManager } from './PreferencesManager'
|
||||
import { ReviewService } from './ReviewService'
|
||||
import { StatusManager } from './StatusManager'
|
||||
|
||||
export class ApplicationGroup extends SNApplicationGroup {
|
||||
constructor() {
|
||||
super(new MobileDeviceInterface())
|
||||
}
|
||||
|
||||
override async initialize(_callback?: any): Promise<void> {
|
||||
await super.initialize({
|
||||
applicationCreator: this.createApplication,
|
||||
})
|
||||
}
|
||||
|
||||
private createApplication = async (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => {
|
||||
const application = new MobileApplication(deviceInterface as MobileDeviceInterface, descriptor.identifier)
|
||||
const internalEventBus = new InternalEventBus()
|
||||
const applicationState = new ApplicationState(application)
|
||||
const reviewService = new ReviewService(application, internalEventBus)
|
||||
const backupsService = new BackupsService(application, internalEventBus)
|
||||
const prefsService = new PreferencesManager(application, internalEventBus)
|
||||
const installationService = new InstallationService(application, internalEventBus)
|
||||
const statusManager = new StatusManager(application, internalEventBus)
|
||||
const filesService = new FilesService(application, internalEventBus)
|
||||
application.setMobileServices({
|
||||
applicationState,
|
||||
reviewService,
|
||||
backupsService,
|
||||
prefsService,
|
||||
installationService,
|
||||
statusManager,
|
||||
filesService,
|
||||
})
|
||||
return application
|
||||
}
|
||||
}
|
||||
683
packages/mobile/src/Lib/ApplicationState.ts
Normal file
683
packages/mobile/src/Lib/ApplicationState.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
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.closeActiveNoteController()
|
||||
|
||||
const noteView = await this.application.editorGroup.createNoteController(undefined, title, selectedTagUuid)
|
||||
|
||||
const defaultEditor = this.application.componentManager.getDefaultEditor()
|
||||
if (defaultEditor) {
|
||||
await associateComponentWithNote(this.application, defaultEditor, this.getActiveNoteController().note)
|
||||
}
|
||||
|
||||
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.closeActiveNoteController()
|
||||
}
|
||||
|
||||
const noteView = await this.application.editorGroup.createNoteController(noteUuid)
|
||||
|
||||
if (note && note.conflictOf) {
|
||||
void InteractionManager.runAfterInteractions(() => {
|
||||
void this.application?.mutator.changeAndSaveItem(note, mutator => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return noteView
|
||||
}
|
||||
|
||||
getActiveNoteController() {
|
||||
return this.application.editorGroup.noteControllers[0]
|
||||
}
|
||||
|
||||
getEditors() {
|
||||
return this.application.editorGroup.noteControllers
|
||||
}
|
||||
|
||||
closeEditor(editor: NoteViewController) {
|
||||
this.notifyOfStateChange(AppStateType.EditorClosed)
|
||||
this.application.editorGroup.closeNoteController(editor)
|
||||
}
|
||||
|
||||
closeActiveEditor() {
|
||||
this.notifyOfStateChange(AppStateType.EditorClosed)
|
||||
this.application.editorGroup.closeActiveNoteController()
|
||||
}
|
||||
|
||||
closeAllEditors() {
|
||||
this.notifyOfStateChange(AppStateType.EditorClosed)
|
||||
this.application.editorGroup.closeAllNoteControllers()
|
||||
}
|
||||
|
||||
editorForNote(uuid: Uuid): NoteViewController | void {
|
||||
for (const editor of this.getEditors()) {
|
||||
if (editor.note?.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['user-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'
|
||||
}
|
||||
}
|
||||
162
packages/mobile/src/Lib/BackupsService.ts
Normal file
162
packages/mobile/src/Lib/BackupsService.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { ApplicationService, ButtonType, Platform } from '@standardnotes/snjs'
|
||||
import { Base64 } from 'js-base64'
|
||||
import { Alert, PermissionsAndroid, Share } from 'react-native'
|
||||
import FileViewer from 'react-native-file-viewer'
|
||||
import RNFS from 'react-native-fs'
|
||||
import Mailer from 'react-native-mail'
|
||||
import { MobileApplication } from './Application'
|
||||
|
||||
export class BackupsService extends ApplicationService {
|
||||
/*
|
||||
On iOS, we can use Share to share a file of arbitrary length.
|
||||
This doesn't work on Android however. Seems to have a very low limit.
|
||||
For Android, we'll use RNFS to save the file to disk, then FileViewer to
|
||||
ask the user what application they would like to open the file with.
|
||||
For .txt files, not many applications handle it. So, we'll want to notify the user
|
||||
the path the file was saved to.
|
||||
*/
|
||||
|
||||
async export(encrypted: boolean): Promise<boolean | void> {
|
||||
const data = encrypted
|
||||
? await this.application.createEncryptedBackupFile()
|
||||
: await this.application.createDecryptedBackupFile()
|
||||
const prettyPrint = 2
|
||||
const stringifiedData = JSON.stringify(data, null, prettyPrint)
|
||||
|
||||
const modifier = encrypted ? 'Encrypted' : 'Decrypted'
|
||||
const filename = `Standard Notes ${modifier} Backup - ${this.formattedDate()}.txt`
|
||||
if (data) {
|
||||
if (this.application?.platform === Platform.Ios) {
|
||||
return this.exportIOS(filename, stringifiedData)
|
||||
} else {
|
||||
const result = await this.showAndroidEmailOrSaveOption()
|
||||
if (result === 'email') {
|
||||
return this.exportViaEmailAndroid(Base64.encode(stringifiedData), filename)
|
||||
} else if (result === 'save') {
|
||||
await this.exportAndroid(filename, stringifiedData)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async showAndroidEmailOrSaveOption() {
|
||||
try {
|
||||
const confirmed = await this.application!.alertService?.confirm(
|
||||
'Choose Export Method',
|
||||
'',
|
||||
'Email',
|
||||
ButtonType.Info,
|
||||
'Save to Disk',
|
||||
)
|
||||
if (confirmed) {
|
||||
return 'email'
|
||||
} else {
|
||||
return 'save'
|
||||
}
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async exportIOS(filename: string, data: string) {
|
||||
return new Promise<boolean>(resolve => {
|
||||
void (this.application! as MobileApplication).getAppState().performActionWithoutStateChangeImpact(async () => {
|
||||
Share.share({
|
||||
title: filename,
|
||||
message: data,
|
||||
})
|
||||
.then(result => {
|
||||
resolve(result.action !== Share.dismissedAction)
|
||||
})
|
||||
.catch(() => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async exportAndroid(filename: string, data: string) {
|
||||
try {
|
||||
let filepath = `${RNFS.ExternalDirectoryPath}/${filename}`
|
||||
const granted = await this.requestStoragePermissionsAndroid()
|
||||
if (granted) {
|
||||
filepath = `${RNFS.DownloadDirectoryPath}/${filename}`
|
||||
}
|
||||
await RNFS.writeFile(filepath, data)
|
||||
void this.showFileSavePromptAndroid(filepath)
|
||||
} catch (err) {
|
||||
console.error('Error exporting backup', err)
|
||||
void this.application.alertService.alert('There was an issue exporting your backup.')
|
||||
}
|
||||
}
|
||||
|
||||
private async openFileAndroid(filepath: string) {
|
||||
return FileViewer.open(filepath)
|
||||
.then(() => {
|
||||
// success
|
||||
return true
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error opening file', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
private async showFileSavePromptAndroid(filepath: string) {
|
||||
const confirmed = await this.application!.alertService?.confirm(
|
||||
`Your backup file has been saved to your local disk at this location:\n\n${filepath}`,
|
||||
'Backup Saved',
|
||||
'Open File',
|
||||
ButtonType.Info,
|
||||
'Done',
|
||||
)
|
||||
if (confirmed) {
|
||||
void this.openFileAndroid(filepath)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private async exportViaEmailAndroid(data: string, filename: string) {
|
||||
return new Promise<boolean>(resolve => {
|
||||
const fileType = '.json' // Android creates a tmp file and expects dot with extension
|
||||
|
||||
let resolved = false
|
||||
Mailer.mail(
|
||||
{
|
||||
subject: 'Standard Notes Backup',
|
||||
recipients: [''],
|
||||
body: '',
|
||||
isHTML: true,
|
||||
attachment: { data, type: fileType, name: filename },
|
||||
},
|
||||
(error: any) => {
|
||||
if (error) {
|
||||
Alert.alert('Error', 'Unable to send email.')
|
||||
}
|
||||
resolved = true
|
||||
resolve(false)
|
||||
},
|
||||
)
|
||||
|
||||
// On Android the Mailer callback event isn't always triggered.
|
||||
setTimeout(function () {
|
||||
if (!resolved) {
|
||||
resolve(true)
|
||||
}
|
||||
}, 2500)
|
||||
})
|
||||
}
|
||||
|
||||
private async requestStoragePermissionsAndroid() {
|
||||
const writeStorageGranted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
|
||||
return writeStorageGranted === PermissionsAndroid.RESULTS.GRANTED
|
||||
}
|
||||
|
||||
/* Utils */
|
||||
|
||||
private formattedDate() {
|
||||
return new Date().getTime()
|
||||
}
|
||||
}
|
||||
339
packages/mobile/src/Lib/ComponentManager.ts
Normal file
339
packages/mobile/src/Lib/ComponentManager.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { MobileTheme } from '@Root/Style/MobileTheme'
|
||||
import FeatureChecksums from '@standardnotes/components/dist/checksums.json'
|
||||
import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features'
|
||||
import {
|
||||
ComponentMutator,
|
||||
EncryptionService,
|
||||
isRightVersionGreaterThanLeft,
|
||||
PermissionDialog,
|
||||
SNApplication,
|
||||
SNComponent,
|
||||
SNComponentManager,
|
||||
SNLog,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { objectToCss } from '@Style/CssParser'
|
||||
import { Base64 } from 'js-base64'
|
||||
import RNFS, { DocumentDirectoryPath } from 'react-native-fs'
|
||||
import StaticServer from 'react-native-static-server'
|
||||
import { unzip } from 'react-native-zip-archive'
|
||||
import { MobileThemeContent } from '../Style/MobileTheme'
|
||||
|
||||
type TFeatureChecksums = {
|
||||
[key in FeatureIdentifier]: {
|
||||
version: string
|
||||
base64: string
|
||||
binary: string
|
||||
}
|
||||
}
|
||||
export enum ComponentLoadingError {
|
||||
FailedDownload = 'FailedDownload',
|
||||
ChecksumMismatch = 'ChecksumMismatch',
|
||||
LocalServerFailure = 'LocalServerFailure',
|
||||
DoesntExist = 'DoesntExist',
|
||||
Unknown = 'Unknown',
|
||||
}
|
||||
|
||||
const STATIC_SERVER_PORT = 8080
|
||||
const BASE_DOCUMENTS_PATH = DocumentDirectoryPath
|
||||
const COMPONENTS_PATH = '/components'
|
||||
|
||||
export class ComponentManager extends SNComponentManager {
|
||||
private mobileActiveTheme?: MobileTheme
|
||||
|
||||
private staticServer!: StaticServer
|
||||
private staticServerUrl!: string
|
||||
private protocolService!: EncryptionService
|
||||
private thirdPartyIndexPaths: Record<string, string> = {}
|
||||
|
||||
public async initialize(protocolService: EncryptionService) {
|
||||
this.loggingEnabled = false
|
||||
this.protocolService = protocolService
|
||||
await this.createServer()
|
||||
}
|
||||
|
||||
private async createServer() {
|
||||
const path = `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}`
|
||||
const server = new StaticServer(STATIC_SERVER_PORT, path, {
|
||||
localOnly: true,
|
||||
})
|
||||
try {
|
||||
const serverUrl = await server.start()
|
||||
this.staticServer = server
|
||||
this.staticServerUrl = serverUrl
|
||||
} catch (e) {
|
||||
void this.alertService.alert(
|
||||
'Unable to start component server. ' +
|
||||
'Editors other than the Plain Editor will fail to load. ' +
|
||||
'Please restart the app and try again.',
|
||||
)
|
||||
SNLog.error(e as any)
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
super.deinit()
|
||||
void this.staticServer!.stop()
|
||||
}
|
||||
|
||||
public isComponentDownloadable(component: SNComponent): boolean {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
return !!downloadUrl
|
||||
}
|
||||
|
||||
public async uninstallComponent(component: SNComponent) {
|
||||
const path = this.pathForComponent(component.identifier)
|
||||
if (await RNFS.exists(path)) {
|
||||
this.log('Deleting dir at', path)
|
||||
await RNFS.unlink(path)
|
||||
}
|
||||
}
|
||||
|
||||
public async doesComponentNeedDownload(component: SNComponent): Promise<boolean> {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw Error('Attempting to download component with no download url')
|
||||
}
|
||||
|
||||
const version = nativeFeature?.version || component.package_info?.version
|
||||
|
||||
const existingPackageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
|
||||
const existingVersion = existingPackageJson?.version
|
||||
this.log('Existing package version', existingVersion)
|
||||
this.log('Latest package version', version)
|
||||
|
||||
const shouldDownload = !existingPackageJson || isRightVersionGreaterThanLeft(existingVersion, version!)
|
||||
|
||||
return shouldDownload
|
||||
}
|
||||
|
||||
public async downloadComponentOffline(component: SNComponent): Promise<ComponentLoadingError | undefined> {
|
||||
const identifier = component.identifier
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
const downloadUrl = nativeFeature?.download_url || component.package_info?.download_url
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw Error('Attempting to download component with no download url')
|
||||
}
|
||||
|
||||
let error
|
||||
try {
|
||||
error = await this.performDownloadComponent(identifier, downloadUrl)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return ComponentLoadingError.Unknown
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return error
|
||||
}
|
||||
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
if (!(await RNFS.exists(componentPath))) {
|
||||
this.log(`No component exists at path ${componentPath}, not using offline component`)
|
||||
return ComponentLoadingError.DoesntExist
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
public nativeFeatureForIdentifier(identifier: FeatureIdentifier) {
|
||||
return GetFeatures().find((feature: FeatureDescription) => feature.identifier === identifier)
|
||||
}
|
||||
|
||||
public isComponentThirdParty(identifier: FeatureIdentifier): boolean {
|
||||
return !this.nativeFeatureForIdentifier(identifier)
|
||||
}
|
||||
|
||||
public async preloadThirdPartyIndexPathFromDisk(identifier: FeatureIdentifier) {
|
||||
const packageJson = await this.getDownloadedComponentPackageJsonFile(identifier)
|
||||
this.thirdPartyIndexPaths[identifier] = packageJson?.sn?.main || 'index.html'
|
||||
}
|
||||
|
||||
private async passesChecksumValidation(filePath: string, featureIdentifier: FeatureIdentifier) {
|
||||
this.log('Performing checksum verification on', filePath)
|
||||
const zipContents = await RNFS.readFile(filePath, 'base64')
|
||||
const checksum = await this.protocolService.crypto.sha256(zipContents)
|
||||
|
||||
const desiredChecksum = (FeatureChecksums as TFeatureChecksums)[featureIdentifier]?.base64
|
||||
if (!desiredChecksum) {
|
||||
this.log(`Checksum is missing for ${featureIdentifier}; aborting installation`)
|
||||
return false
|
||||
}
|
||||
if (checksum !== desiredChecksum) {
|
||||
this.log(`Checksums don't match for ${featureIdentifier}; ${checksum} != ${desiredChecksum}; aborting install`)
|
||||
return false
|
||||
}
|
||||
this.log(`Checksum ${checksum} matches ${desiredChecksum} for ${featureIdentifier}`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async performDownloadComponent(
|
||||
identifier: FeatureIdentifier,
|
||||
downloadUrl: string,
|
||||
): Promise<ComponentLoadingError | undefined> {
|
||||
const tmpLocation = `${BASE_DOCUMENTS_PATH}/${identifier}.zip`
|
||||
|
||||
if (await RNFS.exists(tmpLocation)) {
|
||||
this.log('Deleting file at', tmpLocation)
|
||||
await RNFS.unlink(tmpLocation)
|
||||
}
|
||||
|
||||
this.log('Downloading component', identifier, 'from url', downloadUrl, 'to location', tmpLocation)
|
||||
|
||||
const result = await RNFS.downloadFile({
|
||||
fromUrl: downloadUrl,
|
||||
toFile: tmpLocation,
|
||||
}).promise
|
||||
|
||||
if (!String(result.statusCode).startsWith('2')) {
|
||||
console.error(`Error downloading file ${downloadUrl}`)
|
||||
return ComponentLoadingError.FailedDownload
|
||||
}
|
||||
|
||||
this.log('Finished download to tmp location', tmpLocation)
|
||||
|
||||
const requireChecksumVerification = !!this.nativeFeatureForIdentifier(identifier)
|
||||
if (requireChecksumVerification) {
|
||||
const passes = await this.passesChecksumValidation(tmpLocation, identifier)
|
||||
if (!passes) {
|
||||
return ComponentLoadingError.ChecksumMismatch
|
||||
}
|
||||
}
|
||||
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
|
||||
this.log(`Attempting to unzip ${tmpLocation} to ${componentPath}`)
|
||||
await unzip(tmpLocation, componentPath)
|
||||
this.log('Unzipped component to', componentPath)
|
||||
|
||||
const directoryContents = await RNFS.readDir(componentPath)
|
||||
const isNestedArchive = directoryContents.length === 1 && directoryContents[0].isDirectory()
|
||||
if (isNestedArchive) {
|
||||
this.log('Component download includes base level dir that is not its identifier, fixing...')
|
||||
const nestedDir = directoryContents[0]
|
||||
const tmpMovePath = `${BASE_DOCUMENTS_PATH}/${identifier}`
|
||||
await RNFS.moveFile(nestedDir.path, tmpMovePath)
|
||||
await RNFS.unlink(componentPath)
|
||||
await RNFS.moveFile(tmpMovePath, componentPath)
|
||||
this.log(`Moved directory from ${directoryContents[0].path} to ${componentPath}`)
|
||||
}
|
||||
await RNFS.unlink(tmpLocation)
|
||||
return
|
||||
}
|
||||
|
||||
private pathForComponent(identifier: FeatureIdentifier) {
|
||||
return `${BASE_DOCUMENTS_PATH}${COMPONENTS_PATH}/${identifier}`
|
||||
}
|
||||
|
||||
public async getFile(identifier: FeatureIdentifier, relativePath: string) {
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
if (!(await RNFS.exists(componentPath))) {
|
||||
return undefined
|
||||
}
|
||||
const filePath = `${componentPath}/${relativePath}`
|
||||
if (!(await RNFS.exists(filePath))) {
|
||||
return undefined
|
||||
}
|
||||
const fileContents = await RNFS.readFile(filePath)
|
||||
return fileContents
|
||||
}
|
||||
|
||||
public async getIndexFile(identifier: FeatureIdentifier) {
|
||||
if (this.isComponentThirdParty(identifier)) {
|
||||
await this.preloadThirdPartyIndexPathFromDisk(identifier)
|
||||
}
|
||||
const relativePath = this.getIndexFileRelativePath(identifier)
|
||||
return this.getFile(identifier, relativePath!)
|
||||
}
|
||||
|
||||
private async getDownloadedComponentPackageJsonFile(
|
||||
identifier: FeatureIdentifier,
|
||||
): Promise<Record<string, any> | undefined> {
|
||||
const file = await this.getFile(identifier, 'package.json')
|
||||
if (!file) {
|
||||
return undefined
|
||||
}
|
||||
const packageJson = JSON.parse(file)
|
||||
return packageJson
|
||||
}
|
||||
|
||||
override async presentPermissionsDialog(dialog: PermissionDialog) {
|
||||
const text = `${dialog.component.name} would like to interact with your ${dialog.permissionsString}`
|
||||
const approved = await this.alertService.confirm(text, 'Grant Permissions', 'Continue', undefined, 'Cancel')
|
||||
dialog.callback(approved)
|
||||
}
|
||||
|
||||
private getIndexFileRelativePath(identifier: FeatureIdentifier) {
|
||||
const nativeFeature = this.nativeFeatureForIdentifier(identifier)
|
||||
if (nativeFeature) {
|
||||
return nativeFeature.index_path
|
||||
} else {
|
||||
return this.thirdPartyIndexPaths[identifier]
|
||||
}
|
||||
}
|
||||
|
||||
override urlForComponent(component: SNComponent) {
|
||||
if (component.isTheme() && (component.content as MobileThemeContent).isSystemTheme) {
|
||||
const theme = component as MobileTheme
|
||||
const cssData = objectToCss(theme.mobileContent.variables)
|
||||
const encoded = Base64.encodeURI(cssData)
|
||||
return `data:text/css;base64,${encoded}`
|
||||
}
|
||||
|
||||
if (!this.isComponentDownloadable(component)) {
|
||||
return super.urlForComponent(component)
|
||||
}
|
||||
|
||||
const identifier = component.identifier
|
||||
const componentPath = this.pathForComponent(identifier)
|
||||
const indexFilePath = this.getIndexFileRelativePath(identifier)
|
||||
|
||||
if (!indexFilePath) {
|
||||
throw Error('Third party index path was not preloaded')
|
||||
}
|
||||
|
||||
const splitPackagePath = componentPath.split(COMPONENTS_PATH)
|
||||
const relativePackagePath = splitPackagePath[splitPackagePath.length - 1]
|
||||
const relativeMainFilePath = `${relativePackagePath}/${indexFilePath}`
|
||||
return `${this.staticServerUrl}${relativeMainFilePath}`
|
||||
}
|
||||
|
||||
public setMobileActiveTheme(theme: MobileTheme) {
|
||||
this.mobileActiveTheme = theme
|
||||
this.postActiveThemesToAllViewers()
|
||||
}
|
||||
|
||||
override getActiveThemes() {
|
||||
if (this.mobileActiveTheme) {
|
||||
return [this.mobileActiveTheme]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async preloadThirdPartyThemeIndexPath() {
|
||||
const theme = this.mobileActiveTheme
|
||||
if (!theme) {
|
||||
return
|
||||
}
|
||||
|
||||
const { identifier } = theme
|
||||
if (this.isComponentThirdParty(identifier)) {
|
||||
await this.preloadThirdPartyIndexPathFromDisk(identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function associateComponentWithNote(application: SNApplication, component: SNComponent, note: SNNote) {
|
||||
return application.mutator.changeItem<ComponentMutator>(component, mutator => {
|
||||
mutator.removeDisassociatedItemId(note.uuid)
|
||||
mutator.associateWithItem(note.uuid)
|
||||
})
|
||||
}
|
||||
98
packages/mobile/src/Lib/FilesService.ts
Normal file
98
packages/mobile/src/Lib/FilesService.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ByteChunker, FileSelectionResponse, OnChunkCallback } from '@standardnotes/filepicker'
|
||||
import { FileDownloadProgress } from '@standardnotes/files/dist/Domain/Types/FileDownloadProgress'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { ApplicationService, FileItem } from '@standardnotes/snjs'
|
||||
import { Buffer } from 'buffer'
|
||||
import { Base64 } from 'js-base64'
|
||||
import { PermissionsAndroid, Platform } from 'react-native'
|
||||
import { DocumentPickerResponse } from 'react-native-document-picker'
|
||||
import RNFS, { CachesDirectoryPath, DocumentDirectoryPath, DownloadDirectoryPath, read } from 'react-native-fs'
|
||||
import { Asset } from 'react-native-image-picker'
|
||||
|
||||
type TGetFileDestinationPath = {
|
||||
fileName: string
|
||||
saveInTempLocation?: boolean
|
||||
}
|
||||
|
||||
export class FilesService extends ApplicationService {
|
||||
private fileChunkSizeForReading = 2000000
|
||||
|
||||
getDestinationPath({ fileName, saveInTempLocation = false }: TGetFileDestinationPath): 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
|
||||
}
|
||||
await this.application.alertService.alert(
|
||||
'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
async downloadFileInChunks(
|
||||
file: FileItem,
|
||||
path: string,
|
||||
handleOnChunk: (progress: FileDownloadProgress | undefined) => unknown,
|
||||
): Promise<ClientDisplayableError | undefined> {
|
||||
const response = await this.application.files.downloadFile(file, async (decryptedBytes: Uint8Array, progress) => {
|
||||
const base64String = new Buffer(decryptedBytes).toString('base64')
|
||||
handleOnChunk(progress)
|
||||
|
||||
await RNFS.appendFile(path, base64String, 'base64')
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
getFileName(file: DocumentPickerResponse | Asset) {
|
||||
if ('name' in file) {
|
||||
return file.name
|
||||
}
|
||||
return file.fileName as string
|
||||
}
|
||||
|
||||
async readFile(file: DocumentPickerResponse | Asset, onChunk: OnChunkCallback): Promise<FileSelectionResponse> {
|
||||
const fileUri = (Platform.OS === 'ios' ? decodeURI(file.uri!) : file.uri) as string
|
||||
|
||||
let positionShift = 0
|
||||
let filePortion = ''
|
||||
|
||||
const chunker = new ByteChunker(this.application.files.minimumChunkSize(), onChunk)
|
||||
let isFinalChunk = false
|
||||
|
||||
do {
|
||||
filePortion = await read(fileUri, this.fileChunkSizeForReading, positionShift, 'base64')
|
||||
const bytes = Base64.toUint8Array(filePortion)
|
||||
isFinalChunk = bytes.length < this.fileChunkSizeForReading
|
||||
|
||||
await chunker.addBytes(bytes, isFinalChunk)
|
||||
|
||||
positionShift += this.fileChunkSizeForReading
|
||||
} while (!isFinalChunk)
|
||||
|
||||
const fileName = this.getFileName(file)
|
||||
|
||||
return {
|
||||
name: fileName,
|
||||
mimeType: file.type || '',
|
||||
}
|
||||
}
|
||||
|
||||
sortByName(file1: FileItem, file2: FileItem): number {
|
||||
return file1.name.toLocaleLowerCase() > file2.name.toLocaleLowerCase() ? 1 : -1
|
||||
}
|
||||
|
||||
formatCompletedPercent(percent: number | undefined) {
|
||||
return Math.round(percent || 0)
|
||||
}
|
||||
}
|
||||
68
packages/mobile/src/Lib/InstallationService.ts
Normal file
68
packages/mobile/src/Lib/InstallationService.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import SNReactNative from '@standardnotes/react-native-utils'
|
||||
import { ApplicationService, ButtonType, isNullOrUndefined, StorageValueModes } from '@standardnotes/snjs'
|
||||
import { MobileDeviceInterface } from './Interface'
|
||||
|
||||
const FIRST_RUN_KEY = 'first_run'
|
||||
|
||||
export class InstallationService extends ApplicationService {
|
||||
override async onAppStart() {
|
||||
if (await this.needsWipe()) {
|
||||
await this.wipeData()
|
||||
} else {
|
||||
void this.markApplicationAsRan()
|
||||
}
|
||||
}
|
||||
|
||||
async markApplicationAsRan() {
|
||||
return this.application?.setValue(FIRST_RUN_KEY, false, StorageValueModes.Nonwrapped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs wipe if has keys but no data. However, since "no data" can be incorrectly reported by underlying
|
||||
* AsyncStorage failures, we want to confirm with the user before deleting anything.
|
||||
*/
|
||||
async needsWipe() {
|
||||
const hasNormalKeys = this.application?.hasAccount() || this.application?.hasPasscode()
|
||||
const deviceInterface = this.application?.deviceInterface as MobileDeviceInterface
|
||||
const keychainKey = await deviceInterface?.getRawKeychainValue()
|
||||
const hasKeychainValue = !(
|
||||
isNullOrUndefined(keychainKey) ||
|
||||
(typeof keychainKey === 'object' && Object.keys(keychainKey).length === 0)
|
||||
)
|
||||
|
||||
const firstRunKey = await this.application?.getValue(FIRST_RUN_KEY, StorageValueModes.Nonwrapped)
|
||||
let firstRunKeyMissing = isNullOrUndefined(firstRunKey)
|
||||
/*
|
||||
* Because of migration failure first run key might not be in non wrapped storage
|
||||
*/
|
||||
if (firstRunKeyMissing) {
|
||||
const fallbackFirstRunValue = await this.application?.deviceInterface?.getRawStorageValue(FIRST_RUN_KEY)
|
||||
firstRunKeyMissing = isNullOrUndefined(fallbackFirstRunValue)
|
||||
}
|
||||
return !hasNormalKeys && hasKeychainValue && firstRunKeyMissing
|
||||
}
|
||||
|
||||
/**
|
||||
* On iOS, keychain data is persisted between installs/uninstalls. (https://stackoverflow.com/questions/4747404/delete-keychain-items-when-an-app-is-uninstalled)
|
||||
* This prevents the user from deleting the app and reinstalling if they forgot their local passocde
|
||||
* or if fingerprint scanning isn't working. By deleting all data on first run, we allow the user to reset app
|
||||
* state after uninstall.
|
||||
*/
|
||||
async wipeData() {
|
||||
const confirmed = await this.application?.alertService?.confirm(
|
||||
"We've detected a previous installation of Standard Notes based on your keychain data. You must wipe all data from previous installation to continue.\n\nIf you're seeing this message in error, it might mean we're having issues loading your local database. Please restart the app and try again.",
|
||||
'Previous Installation',
|
||||
'Delete Local Data',
|
||||
ButtonType.Danger,
|
||||
'Quit App',
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
await this.application?.deviceInterface?.removeAllRawStorageValues()
|
||||
await this.application?.deviceInterface?.removeAllRawDatabasePayloads(this.application?.identifier)
|
||||
await this.application?.deviceInterface?.clearRawKeychainValue()
|
||||
} else {
|
||||
SNReactNative.exitApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
325
packages/mobile/src/Lib/Interface.ts
Normal file
325
packages/mobile/src/Lib/Interface.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import AsyncStorage from '@react-native-community/async-storage'
|
||||
import {
|
||||
ApplicationIdentifier,
|
||||
DeviceInterface,
|
||||
Environment,
|
||||
LegacyRawKeychainValue,
|
||||
NamespacedRootKeyInKeychain,
|
||||
RawKeychainValue,
|
||||
TransferPayload,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Alert, Linking, Platform } from 'react-native'
|
||||
import FingerprintScanner from 'react-native-fingerprint-scanner'
|
||||
import Keychain from './Keychain'
|
||||
|
||||
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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 MobileDeviceInterface implements DeviceInterface {
|
||||
environment: Environment.Mobile = Environment.Mobile
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
deinit() {}
|
||||
|
||||
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.includes(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (isLegacyIdentifier(identifier)) {
|
||||
return keychain as unknown as NamespacedRootKeyInKeychain
|
||||
}
|
||||
|
||||
if (!keychain) {
|
||||
return
|
||||
}
|
||||
|
||||
return keychain[identifier]
|
||||
}
|
||||
|
||||
async setNamespacedKeychainValue(
|
||||
value: NamespacedRootKeyInKeychain,
|
||||
identifier: ApplicationIdentifier,
|
||||
): Promise<void> {
|
||||
if (isLegacyIdentifier(identifier)) {
|
||||
await Keychain.setKeys(value)
|
||||
}
|
||||
|
||||
let keychain = await this.getRawKeychainValue()
|
||||
|
||||
if (!keychain) {
|
||||
keychain = {}
|
||||
}
|
||||
|
||||
await Keychain.setKeys({
|
||||
...keychain,
|
||||
[identifier]: value,
|
||||
})
|
||||
}
|
||||
|
||||
async clearNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<void> {
|
||||
if (isLegacyIdentifier(identifier)) {
|
||||
await this.clearRawKeychainValue()
|
||||
}
|
||||
|
||||
const keychain = await this.getRawKeychainValue()
|
||||
|
||||
if (!keychain) {
|
||||
return
|
||||
}
|
||||
|
||||
delete keychain[identifier]
|
||||
await Keychain.setKeys(keychain)
|
||||
}
|
||||
|
||||
async getDeviceBiometricsAvailability() {
|
||||
try {
|
||||
await FingerprintScanner.isSensorAvailable()
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
|
||||
return Keychain.getKeys()
|
||||
}
|
||||
|
||||
async clearRawKeychainValue(): Promise<void> {
|
||||
await Keychain.clearKeys()
|
||||
}
|
||||
|
||||
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.clearRawKeychainValue()
|
||||
|
||||
await this.removeAllRawStorageValues()
|
||||
|
||||
return { killsApplication: false }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
performSoftReset() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
performHardReset() {}
|
||||
|
||||
isDeviceDestroyed() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
31
packages/mobile/src/Lib/Keychain.ts
Normal file
31
packages/mobile/src/Lib/Keychain.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { RawKeychainValue } from '@standardnotes/snjs'
|
||||
import * as RCTKeychain from 'react-native-keychain'
|
||||
|
||||
export default class Keychain {
|
||||
static async setKeys(keys: object) {
|
||||
const iOSOptions = {
|
||||
accessible: RCTKeychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
}
|
||||
return RCTKeychain.setGenericPassword('sn', JSON.stringify(keys), iOSOptions)
|
||||
}
|
||||
|
||||
static async getKeys(): Promise<RawKeychainValue | undefined | null> {
|
||||
return RCTKeychain.getGenericPassword()
|
||||
.then(function (credentials) {
|
||||
if (!credentials || !credentials.password) {
|
||||
return null
|
||||
} else {
|
||||
const keys = JSON.parse(credentials.password)
|
||||
return keys
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error("Keychain couldn't be accessed! Maybe no value set?", error)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
static async clearKeys() {
|
||||
return RCTKeychain.resetGenericPassword()
|
||||
}
|
||||
}
|
||||
21
packages/mobile/src/Lib/NavigationService.ts
Normal file
21
packages/mobile/src/Lib/NavigationService.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NavigationContainerRef, StackActions } from '@react-navigation/native'
|
||||
import { AppStackNavigatorParamList } from '@Root/AppStack'
|
||||
import { ModalStackNavigatorParamList } from '@Root/ModalStack'
|
||||
import * as React from 'react'
|
||||
|
||||
export const navigationRef =
|
||||
React.createRef<NavigationContainerRef<AppStackNavigatorParamList & ModalStackNavigatorParamList>>()
|
||||
|
||||
export function navigate(name: keyof AppStackNavigatorParamList | keyof ModalStackNavigatorParamList, params?: any) {
|
||||
navigationRef.current?.navigate(name, params)
|
||||
}
|
||||
|
||||
export function push(name: string, params?: any) {
|
||||
const pushAction = StackActions.push(name, params)
|
||||
|
||||
navigationRef.current?.dispatch(pushAction)
|
||||
}
|
||||
|
||||
export function goBack() {
|
||||
navigationRef.current?.goBack()
|
||||
}
|
||||
71
packages/mobile/src/Lib/PreferencesManager.ts
Normal file
71
packages/mobile/src/Lib/PreferencesManager.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ApplicationService, isNullOrUndefined, PrefKey, removeFromArray } from '@standardnotes/snjs'
|
||||
import { MobileApplication } from './Application'
|
||||
|
||||
type Preferences = Record<PrefKey, any>
|
||||
type PreferencesObserver = () => Promise<void> | void
|
||||
export const LAST_EXPORT_DATE_KEY = 'LastExportDateKey'
|
||||
const PREFS_KEY = 'preferences'
|
||||
|
||||
export class PreferencesManager extends ApplicationService {
|
||||
private userPreferences!: Preferences
|
||||
observers: PreferencesObserver[] = []
|
||||
|
||||
/** @override */
|
||||
override async onAppLaunch() {
|
||||
void super.onAppLaunch()
|
||||
void this.loadPreferences()
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.observers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an observer for preferences loaded event
|
||||
* @returns function that unregisters this observer
|
||||
*/
|
||||
public addPreferencesLoadedObserver(callback: PreferencesObserver) {
|
||||
this.observers.push(callback)
|
||||
return () => {
|
||||
removeFromArray(this.observers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
notifyObserversOfPreferencesLoaded() {
|
||||
for (const observer of this.observers) {
|
||||
void observer()
|
||||
}
|
||||
}
|
||||
|
||||
get mobileApplication() {
|
||||
return this.application as MobileApplication
|
||||
}
|
||||
|
||||
private async loadPreferences() {
|
||||
const preferences = await this.application.getValue(PREFS_KEY)
|
||||
this.userPreferences = (preferences as Preferences) ?? {}
|
||||
this.notifyObserversOfPreferencesLoaded()
|
||||
}
|
||||
|
||||
private async saveSingleton() {
|
||||
return this.application.setValue(PREFS_KEY, this.userPreferences)
|
||||
}
|
||||
|
||||
private async savePreference(key: PrefKey, value: any) {
|
||||
return this.application.setPreference(key, value)
|
||||
}
|
||||
|
||||
getValue(key: PrefKey, defaultValue?: any) {
|
||||
if (!this.userPreferences) {
|
||||
return defaultValue
|
||||
}
|
||||
const value = this.application.getPreference(key)
|
||||
return !isNullOrUndefined(value) ? value : defaultValue
|
||||
}
|
||||
|
||||
async setUserPrefValue(key: PrefKey, value: any) {
|
||||
this.userPreferences[key] = value
|
||||
await this.saveSingleton()
|
||||
await this.savePreference(key, value)
|
||||
}
|
||||
}
|
||||
179
packages/mobile/src/Lib/ReactNativeCrypto.ts
Normal file
179
packages/mobile/src/Lib/ReactNativeCrypto.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Base64String,
|
||||
HexString,
|
||||
PureCryptoInterface,
|
||||
SodiumConstant,
|
||||
StreamDecryptorResult,
|
||||
timingSafeEqual,
|
||||
Utf8String,
|
||||
} from '@standardnotes/sncrypto-common'
|
||||
import { NativeModules } from 'react-native'
|
||||
import * as Sodium from 'react-native-sodium-jsi'
|
||||
|
||||
const { Aes } = NativeModules
|
||||
|
||||
export class SNReactNativeCrypto implements PureCryptoInterface {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
deinit(): void {}
|
||||
public timingSafeEqual(a: string, b: string) {
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
pbkdf2(password: Utf8String, salt: Utf8String, iterations: number, length: number): Promise<string | null> {
|
||||
return Aes.pbkdf2(password, salt, iterations, length)
|
||||
}
|
||||
|
||||
public generateRandomKey(bits: number): string {
|
||||
const bytes = bits / 8
|
||||
const result = Sodium.randombytes_buf(bytes)
|
||||
return result
|
||||
}
|
||||
|
||||
aes256CbcEncrypt(plaintext: Utf8String, iv: HexString, key: HexString): Promise<Base64String> {
|
||||
return Aes.encrypt(plaintext, key, iv)
|
||||
}
|
||||
|
||||
async aes256CbcDecrypt(ciphertext: Base64String, iv: HexString, key: HexString): Promise<Utf8String | null> {
|
||||
try {
|
||||
return Aes.decrypt(ciphertext, key, iv)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async hmac256(message: Utf8String, key: HexString): Promise<HexString | null> {
|
||||
try {
|
||||
return Aes.hmac256(message, key)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public async sha256(text: string): Promise<string> {
|
||||
return Aes.sha256(text)
|
||||
}
|
||||
|
||||
public unsafeSha1(text: string): Promise<string> {
|
||||
return Aes.sha1(text)
|
||||
}
|
||||
|
||||
public argon2(password: Utf8String, salt: HexString, iterations: number, bytes: number, length: number): HexString {
|
||||
return Sodium.crypto_pwhash(length, password, salt, iterations, bytes, Sodium.constants.crypto_pwhash_ALG_DEFAULT)
|
||||
}
|
||||
|
||||
xchacha20Encrypt(plaintext: Utf8String, nonce: HexString, key: HexString, assocData: Utf8String): Base64String {
|
||||
return Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, nonce, key, assocData)
|
||||
}
|
||||
|
||||
public xchacha20Decrypt(
|
||||
ciphertext: Base64String,
|
||||
nonce: HexString,
|
||||
key: HexString,
|
||||
assocData: Utf8String,
|
||||
): string | null {
|
||||
try {
|
||||
const result = Sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, nonce, key, assocData)
|
||||
return result
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public xchacha20StreamInitEncryptor(key: HexString): Sodium.MobileStreamEncryptor {
|
||||
const encryptor = Sodium.crypto_secretstream_xchacha20poly1305_init_push(key)
|
||||
return encryptor
|
||||
}
|
||||
|
||||
public xchacha20StreamEncryptorPush(
|
||||
encryptor: Sodium.MobileStreamEncryptor,
|
||||
plainBuffer: Uint8Array,
|
||||
assocData: Utf8String,
|
||||
tag: SodiumConstant = SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PUSH,
|
||||
): Uint8Array {
|
||||
const encryptedBuffer = Sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
encryptor,
|
||||
plainBuffer.buffer,
|
||||
assocData,
|
||||
tag,
|
||||
)
|
||||
return new Uint8Array(encryptedBuffer)
|
||||
}
|
||||
|
||||
public xchacha20StreamInitDecryptor(header: Base64String, key: HexString): Sodium.MobileStreamDecryptor {
|
||||
const decryptor = Sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key)
|
||||
return decryptor
|
||||
}
|
||||
|
||||
public xchacha20StreamDecryptorPush(
|
||||
decryptor: Sodium.MobileStreamDecryptor,
|
||||
encryptedBuffer: Uint8Array,
|
||||
assocData: Utf8String,
|
||||
): StreamDecryptorResult | false {
|
||||
if (encryptedBuffer.length < SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES) {
|
||||
throw new Error('Invalid ciphertext size')
|
||||
}
|
||||
|
||||
const result = Sodium.crypto_secretstream_xchacha20poly1305_pull(decryptor, encryptedBuffer.buffer, assocData)
|
||||
|
||||
if (!result) {
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
message: new Uint8Array(result.message),
|
||||
tag: result.tag,
|
||||
}
|
||||
}
|
||||
|
||||
public generateUUID() {
|
||||
const randomBuf = Sodium.randombytes_buf(16)
|
||||
const tempBuf = new Uint8Array(randomBuf.length / 2)
|
||||
|
||||
for (let i = 0; i < randomBuf.length; i += 2) {
|
||||
tempBuf[i / 2] = parseInt(randomBuf.substring(i, i + 2), 16)
|
||||
}
|
||||
|
||||
const buf = new Uint32Array(tempBuf.buffer)
|
||||
let idx = -1
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
idx++
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const r = (buf[idx >> 3] >> ((idx % 8) * 4)) & 15
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
public base64Encode(text: Utf8String): string {
|
||||
return Sodium.to_base64(text)
|
||||
}
|
||||
|
||||
public base64Decode(base64String: Base64String): string {
|
||||
return Sodium.from_base64(base64String)
|
||||
}
|
||||
|
||||
public base64URLEncode(text: string): string {
|
||||
return Sodium.to_base64(text, Sodium.constants.base64_variant_VARIANT_URLSAFE_NO_PADDING)
|
||||
}
|
||||
|
||||
public hmac1(): Promise<HexString | null> {
|
||||
throw new Error('hmac1 is not implemented on mobile')
|
||||
}
|
||||
|
||||
public generateOtpSecret(): Promise<string> {
|
||||
throw new Error('generateOtpSecret is not implemented on mobile')
|
||||
}
|
||||
|
||||
public hotpToken(): Promise<string> {
|
||||
throw new Error('hotpToken is not implemented on mobile')
|
||||
}
|
||||
|
||||
public totpToken(): Promise<string> {
|
||||
throw new Error('totpToken is not implemented on mobile')
|
||||
}
|
||||
}
|
||||
25
packages/mobile/src/Lib/ReviewService.ts
Normal file
25
packages/mobile/src/Lib/ReviewService.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApplicationService, Platform } from '@standardnotes/snjs'
|
||||
import * as StoreReview from 'react-native-store-review'
|
||||
|
||||
const RUN_COUNTS_BEFORE_REVIEW = [18, 45, 105]
|
||||
|
||||
export class ReviewService extends ApplicationService {
|
||||
override async onAppLaunch() {
|
||||
if (this.application?.platform === Platform.Android || !StoreReview.isAvailable) {
|
||||
return
|
||||
}
|
||||
const runCount = await this.getRunCount()
|
||||
void this.setRunCount(runCount + 1)
|
||||
if (RUN_COUNTS_BEFORE_REVIEW.includes(runCount)) {
|
||||
setTimeout(function () {
|
||||
StoreReview.requestReview()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
async getRunCount() {
|
||||
return Number(this.application?.getValue('runCount'))
|
||||
}
|
||||
async setRunCount(runCount: number) {
|
||||
return this.application?.setValue('runCount', runCount)
|
||||
}
|
||||
}
|
||||
426
packages/mobile/src/Lib/SnjsHelperHooks.ts
Normal file
426
packages/mobile/src/Lib/SnjsHelperHooks.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
|
||||
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ButtonType,
|
||||
isSameDay,
|
||||
NoteMutator,
|
||||
NoteViewController,
|
||||
SNNote,
|
||||
StorageEncryptionPolicy,
|
||||
} from '@standardnotes/snjs'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { LockStateType } from './ApplicationState'
|
||||
|
||||
export const useSignedIn = (signedInCallback?: () => void, signedOutCallback?: () => void) => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
const [isLocked] = useIsLocked()
|
||||
|
||||
// State
|
||||
const [signedIn, setSignedIn] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true
|
||||
const getSignedIn = async () => {
|
||||
if (mounted && !isLocked) {
|
||||
setSignedIn(!application.noAccount())
|
||||
}
|
||||
}
|
||||
void getSignedIn()
|
||||
const removeSignedInObserver = application.addEventObserver(async event => {
|
||||
if (event === ApplicationEvent.Launched) {
|
||||
void getSignedIn()
|
||||
}
|
||||
if (event === ApplicationEvent.SignedIn) {
|
||||
setSignedIn(true)
|
||||
signedInCallback && signedInCallback()
|
||||
} else if (event === ApplicationEvent.SignedOut) {
|
||||
setSignedIn(false)
|
||||
signedOutCallback && signedOutCallback()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
removeSignedInObserver && removeSignedInObserver()
|
||||
}
|
||||
}, [application, signedInCallback, signedOutCallback, isLocked])
|
||||
|
||||
return [signedIn]
|
||||
}
|
||||
|
||||
export const useOutOfSync = () => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
// State
|
||||
const [outOfSync, setOutOfSync] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true
|
||||
const getOutOfSync = async () => {
|
||||
const outOfSyncInitial = await application.sync.isOutOfSync()
|
||||
if (isMounted) {
|
||||
setOutOfSync(Boolean(outOfSyncInitial))
|
||||
}
|
||||
}
|
||||
void getOutOfSync()
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [application])
|
||||
|
||||
React.useEffect(() => {
|
||||
const removeSignedInObserver = application.addEventObserver(async event => {
|
||||
if (event === ApplicationEvent.EnteredOutOfSync) {
|
||||
setOutOfSync(true)
|
||||
} else if (event === ApplicationEvent.ExitedOutOfSync) {
|
||||
setOutOfSync(false)
|
||||
}
|
||||
})
|
||||
|
||||
return removeSignedInObserver
|
||||
}, [application])
|
||||
|
||||
return [outOfSync]
|
||||
}
|
||||
|
||||
export const useIsLocked = () => {
|
||||
// Context
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
// State
|
||||
const [isLocked, setIsLocked] = React.useState<boolean>(() => {
|
||||
if (!application || !application.getAppState()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(application?.getAppState().locked)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const removeSignedInObserver = application?.getAppState().addLockStateChangeObserver(event => {
|
||||
if (isMounted) {
|
||||
if (event === LockStateType.Locked) {
|
||||
setIsLocked(true)
|
||||
}
|
||||
if (event === LockStateType.Unlocked) {
|
||||
setIsLocked(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
removeSignedInObserver && removeSignedInObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
return [isLocked]
|
||||
}
|
||||
|
||||
export const useHasEditor = () => {
|
||||
// Context
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
// State
|
||||
const [hasEditor, setHasEditor] = React.useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const removeEditorObserver = application?.editorGroup.addActiveControllerChangeObserver(newEditor => {
|
||||
setHasEditor(Boolean(newEditor))
|
||||
})
|
||||
return removeEditorObserver
|
||||
}, [application])
|
||||
|
||||
return [hasEditor]
|
||||
}
|
||||
|
||||
export const useSyncStatus = () => {
|
||||
// Context
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
// State
|
||||
const [completedInitialSync, setCompletedInitialSync] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [decrypting, setDecrypting] = React.useState(false)
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
|
||||
const setStatus = useCallback(
|
||||
(status = '') => {
|
||||
application?.getStatusManager().setMessage(SCREEN_NOTES, status)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const updateLocalDataStatus = useCallback(() => {
|
||||
const syncStatus = application!.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
const encryption =
|
||||
application!.isEncryptionAvailable() &&
|
||||
application!.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
|
||||
|
||||
if (stats.localDataCurrent === 0 || stats.localDataTotal === 0 || stats.localDataDone) {
|
||||
setStatus()
|
||||
return
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items…`
|
||||
const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
|
||||
setStatus(loadingStatus)
|
||||
}, [application, setStatus])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const isEncryptionAvailable =
|
||||
application!.isEncryptionAvailable() &&
|
||||
application!.getStorageEncryptionPolicy() === StorageEncryptionPolicy.Default
|
||||
if (mounted) {
|
||||
setDecrypting(!completedInitialSync && isEncryptionAvailable)
|
||||
updateLocalDataStatus()
|
||||
setLoading(!completedInitialSync && !isEncryptionAvailable)
|
||||
}
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [application, completedInitialSync, updateLocalDataStatus])
|
||||
|
||||
const updateSyncStatus = useCallback(() => {
|
||||
const syncStatus = application!.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
if (syncStatus.hasError()) {
|
||||
setRefreshing(false)
|
||||
setStatus('Unable to Sync')
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`
|
||||
setStatus(text)
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
setStatus(`Syncing ${stats.uploadCompletionCount}/${stats.uploadTotalCount} items...`)
|
||||
} else if (syncStatus.syncInProgress && !completedInitialSync) {
|
||||
setStatus('Syncing…')
|
||||
} else {
|
||||
setStatus()
|
||||
}
|
||||
}, [application, completedInitialSync, setStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeAppEvents = application?.addEventObserver(async eventName => {
|
||||
if (eventName === ApplicationEvent.LocalDataIncrementalLoad) {
|
||||
updateLocalDataStatus()
|
||||
} else if (eventName === ApplicationEvent.SyncStatusChanged || eventName === ApplicationEvent.FailedSync) {
|
||||
updateSyncStatus()
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
setDecrypting(false)
|
||||
setLoading(false)
|
||||
updateLocalDataStatus()
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
if (completedInitialSync) {
|
||||
setRefreshing(false)
|
||||
} else {
|
||||
setCompletedInitialSync(true)
|
||||
}
|
||||
setLoading(false)
|
||||
updateSyncStatus()
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseReadError) {
|
||||
void application!.alertService!.alert('Unable to load local storage. Please restart the app and try again.')
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
void application!.alertService!.alert('Unable to write to local storage. Please restart the app and try again.')
|
||||
} else if (eventName === ApplicationEvent.SignedIn) {
|
||||
setLoading(true)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeAppEvents
|
||||
}, [application, completedInitialSync, setStatus, updateLocalDataStatus, updateSyncStatus])
|
||||
|
||||
const startRefreshing = () => {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
return [loading, decrypting, refreshing, startRefreshing] as [boolean, boolean, boolean, () => void]
|
||||
}
|
||||
|
||||
export const useDeleteNoteWithPrivileges = (
|
||||
note: SNNote,
|
||||
onDeleteCallback: () => void,
|
||||
onTrashCallback: () => void,
|
||||
editor?: NoteViewController,
|
||||
) => {
|
||||
// Context
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
const trashNote = useCallback(async () => {
|
||||
const title = 'Move to Trash'
|
||||
const message = 'Are you sure you want to move this note to the trash?'
|
||||
|
||||
const confirmed = await application?.alertService?.confirm(message, title, 'Confirm', ButtonType.Danger)
|
||||
if (confirmed) {
|
||||
onTrashCallback()
|
||||
}
|
||||
}, [application?.alertService, onTrashCallback])
|
||||
|
||||
const deleteNotePermanently = useCallback(async () => {
|
||||
const title = `Delete ${note!.title}`
|
||||
const message = 'Are you sure you want to permanently delete this note?'
|
||||
if (editor?.isTemplateNote) {
|
||||
void application?.alertService!.alert(
|
||||
'This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.',
|
||||
)
|
||||
return
|
||||
}
|
||||
const confirmed = await application?.alertService?.confirm(message, title, 'Delete', ButtonType.Danger, 'Cancel')
|
||||
if (confirmed) {
|
||||
onDeleteCallback()
|
||||
}
|
||||
}, [application?.alertService, editor?.isTemplateNote, note, onDeleteCallback])
|
||||
|
||||
const deleteNote = useCallback(
|
||||
async (permanently: boolean) => {
|
||||
if (note?.locked) {
|
||||
void application?.alertService.alert(
|
||||
"This note has editing disabled. If you'd like to delete it, enable editing on it, and try again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (permanently) {
|
||||
void deleteNotePermanently()
|
||||
} else {
|
||||
void trashNote()
|
||||
}
|
||||
},
|
||||
[application, deleteNotePermanently, note?.locked, trashNote],
|
||||
)
|
||||
|
||||
return [deleteNote]
|
||||
}
|
||||
|
||||
export const useProtectionSessionExpiry = () => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
const getProtectionsDisabledUntil = React.useCallback(() => {
|
||||
const protectionExpiry = application?.getProtectionSessionExpiryDate()
|
||||
const now = new Date()
|
||||
|
||||
if (protectionExpiry && protectionExpiry > now) {
|
||||
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||
let f: Intl.DateTimeFormat
|
||||
|
||||
if (isSameDay(protectionExpiry, now)) {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
} else {
|
||||
f = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return f.format(protectionExpiry)
|
||||
} else {
|
||||
if (isSameDay(protectionExpiry, now)) {
|
||||
return protectionExpiry.toLocaleTimeString()
|
||||
} else {
|
||||
return `${protectionExpiry.toDateString()} ${protectionExpiry.toLocaleTimeString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [application])
|
||||
|
||||
// State
|
||||
const [protectionsDisabledUntil, setProtectionsDisabledUntil] = React.useState(getProtectionsDisabledUntil())
|
||||
|
||||
useEffect(() => {
|
||||
const removeProtectionLengthSubscriber = application?.addEventObserver(async event => {
|
||||
if ([ApplicationEvent.UnprotectedSessionBegan, ApplicationEvent.UnprotectedSessionExpired].includes(event)) {
|
||||
setProtectionsDisabledUntil(getProtectionsDisabledUntil())
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
removeProtectionLengthSubscriber && removeProtectionLengthSubscriber()
|
||||
}
|
||||
}, [application, getProtectionsDisabledUntil])
|
||||
|
||||
return [protectionsDisabledUntil]
|
||||
}
|
||||
|
||||
export const useChangeNoteChecks = (note: SNNote | undefined, editor: NoteViewController | undefined = undefined) => {
|
||||
// Context
|
||||
const application = useSafeApplicationContext()
|
||||
|
||||
const canChangeNote = useCallback(async () => {
|
||||
if (!note) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (editor && editor.isTemplateNote) {
|
||||
await editor.insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (!application.items.findItem(note.uuid)) {
|
||||
void application.alertService!.alert(
|
||||
"The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.",
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, [application, editor, note])
|
||||
|
||||
return [canChangeNote]
|
||||
}
|
||||
|
||||
export const useChangeNote = (note: SNNote | undefined, editor: NoteViewController | undefined = undefined) => {
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
const [canChangeNote] = useChangeNoteChecks(note, editor)
|
||||
|
||||
const changeNote = useCallback(
|
||||
async (mutate: (mutator: NoteMutator) => void, updateTimestamps: boolean) => {
|
||||
if (await canChangeNote()) {
|
||||
await application?.mutator.changeAndSaveItem(
|
||||
note!,
|
||||
mutator => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
mutate(noteMutator)
|
||||
},
|
||||
updateTimestamps,
|
||||
)
|
||||
}
|
||||
},
|
||||
[application, note, canChangeNote],
|
||||
)
|
||||
|
||||
return [changeNote]
|
||||
}
|
||||
|
||||
export const useProtectOrUnprotectNote = (
|
||||
note: SNNote | undefined,
|
||||
editor: NoteViewController | undefined = undefined,
|
||||
) => {
|
||||
// Context
|
||||
const application = React.useContext(ApplicationContext)
|
||||
|
||||
const [canChangeNote] = useChangeNoteChecks(note, editor)
|
||||
|
||||
const protectOrUnprotectNote = useCallback(async () => {
|
||||
if (await canChangeNote()) {
|
||||
if (note!.protected) {
|
||||
await application?.mutator.unprotectNote(note!)
|
||||
} else {
|
||||
await application?.mutator.protectNote(note!)
|
||||
}
|
||||
}
|
||||
}, [application, note, canChangeNote])
|
||||
|
||||
return [protectOrUnprotectNote]
|
||||
}
|
||||
73
packages/mobile/src/Lib/StatusManager.ts
Normal file
73
packages/mobile/src/Lib/StatusManager.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SCREEN_COMPOSE, SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import { ApplicationService, removeFromArray } from '@standardnotes/snjs'
|
||||
|
||||
export type ScreenStatus = {
|
||||
status: string
|
||||
color?: string
|
||||
}
|
||||
type StatusState = {
|
||||
[SCREEN_NOTES]: ScreenStatus
|
||||
[SCREEN_COMPOSE]: ScreenStatus
|
||||
}
|
||||
type HeaderStatusObserverCallback = (status: StatusState) => void
|
||||
|
||||
export class StatusManager extends ApplicationService {
|
||||
private messages: StatusState = {
|
||||
[SCREEN_NOTES]: {
|
||||
status: '',
|
||||
},
|
||||
[SCREEN_COMPOSE]: {
|
||||
status: '',
|
||||
},
|
||||
}
|
||||
private observers: HeaderStatusObserverCallback[] = []
|
||||
|
||||
override deinit() {
|
||||
this.observers = []
|
||||
this.messages = {
|
||||
[SCREEN_NOTES]: {
|
||||
status: '',
|
||||
},
|
||||
[SCREEN_COMPOSE]: {
|
||||
status: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an observer for UI header status change
|
||||
* @returns function that unregisters this observer
|
||||
*/
|
||||
public addHeaderStatusObserver(callback: HeaderStatusObserverCallback) {
|
||||
this.observers.push(callback)
|
||||
return () => {
|
||||
removeFromArray(this.observers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES, message: string, color?: string) {
|
||||
this.messages[screen] = {
|
||||
status: message,
|
||||
color,
|
||||
}
|
||||
this.notifyObservers()
|
||||
}
|
||||
|
||||
hasMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES) {
|
||||
const message = this.getMessage(screen)
|
||||
if (!message || message.status.length === 0) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
getMessage(screen: typeof SCREEN_COMPOSE | typeof SCREEN_NOTES) {
|
||||
return this.messages[screen]
|
||||
}
|
||||
|
||||
private notifyObservers() {
|
||||
for (const observer of this.observers) {
|
||||
observer(this.messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/mobile/src/Lib/Types.ts
Normal file
5
packages/mobile/src/Lib/Types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ToastType {
|
||||
Success = 'success',
|
||||
Info = 'info',
|
||||
Error = 'error',
|
||||
}
|
||||
42
packages/mobile/src/Lib/Utils.ts
Normal file
42
packages/mobile/src/Lib/Utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TEnvironment } from '@Root/App'
|
||||
|
||||
export function isNullOrUndefined(value: unknown) {
|
||||
return value === null || value === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string with non-alphanumeric characters stripped out
|
||||
*/
|
||||
export function stripNonAlphanumeric(str: string) {
|
||||
return str.replace(/\W/g, '')
|
||||
}
|
||||
|
||||
export function isMatchCaseInsensitive(a: string, b: string) {
|
||||
return a.toLowerCase() === b.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Date object from a JSON stringified date
|
||||
*/
|
||||
export function dateFromJsonString(str: string) {
|
||||
if (str) {
|
||||
return new Date(JSON.parse(str))
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean representing whether two dates are on the same day
|
||||
*/
|
||||
export function isSameDay(dateA: Date, dateB: Date) {
|
||||
return (
|
||||
dateA.getFullYear() === dateB.getFullYear() &&
|
||||
dateA.getMonth() === dateB.getMonth() &&
|
||||
dateA.getDate() === dateB.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
export function isUnfinishedFeaturesEnabled(env: TEnvironment): boolean {
|
||||
return env === 'dev' || __DEV__
|
||||
}
|
||||
3
packages/mobile/src/Lib/constants.ts
Normal file
3
packages/mobile/src/Lib/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum ErrorMessage {
|
||||
GeneralText = 'An error occurred. Please try again later.',
|
||||
}
|
||||
12
packages/mobile/src/Lib/moment.ts
Normal file
12
packages/mobile/src/Lib/moment.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// moment.js
|
||||
import moment from 'moment'
|
||||
import { NativeModules, Platform } from 'react-native'
|
||||
|
||||
// moment.js
|
||||
const locale =
|
||||
Platform.OS === 'android'
|
||||
? NativeModules.I18nManager.localeIdentifier
|
||||
: NativeModules.SettingsManager.settings.AppleLocale
|
||||
moment.locale(locale)
|
||||
|
||||
export default moment
|
||||
3
packages/mobile/src/Lib/package.json
Normal file
3
packages/mobile/src/Lib/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "@Lib"
|
||||
}
|
||||
Reference in New Issue
Block a user