From 30ca9c1529c60aced855dddfe787f4bbfc0d1e6d Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Sat, 18 Jul 2026 12:04:12 +0500 Subject: [PATCH] mobile: settings-module fixes --- .../app/components/delay-layout/index.tsx | 4 +- apps/mobile/app/components/toast/index.tsx | 6 +- .../app/screens/settings/components/2fa.tsx | 26 +- .../settings/components/picker/index.tsx | 4 +- .../settings/components/picker/pickers.tsx | 2 +- .../settings/components/sound-picker.tsx | 12 +- apps/mobile/app/screens/settings/group.tsx | 2 +- .../app/screens/settings/groups/account.ts | 10 +- .../app/screens/settings/groups/customize.ts | 16 +- .../screens/settings/groups/productivity.ts | 8 +- apps/mobile/app/screens/settings/home.tsx | 4 +- .../screens/settings/restore-backup/index.tsx | 343 +-------- .../restore-backup/restore-progress.tsx | 664 ++++++++++++++++++ .../app/screens/settings/verify-user.ts | 5 + apps/mobile/fonts/MaterialCommunityIcons.ttf | Bin 23560 -> 23284 bytes .../fonts/notesnook-icons.glyphmap.json | 2 +- apps/mobile/fonts/notesnook-icons.ttf | Bin 37364 -> 37620 bytes .../ios/nanoicons-fonts/notesnook-icons.ttf | Bin 37364 -> 37620 bytes packages/icons/svgs/article-ny-times.svg | 3 + packages/intl/src/strings.ts | 2 +- tsconfig.tsbuildinfo | 1 + 21 files changed, 761 insertions(+), 353 deletions(-) create mode 100644 apps/mobile/app/screens/settings/restore-backup/restore-progress.tsx create mode 100644 packages/icons/svgs/article-ny-times.svg create mode 100644 tsconfig.tsbuildinfo diff --git a/apps/mobile/app/components/delay-layout/index.tsx b/apps/mobile/app/components/delay-layout/index.tsx index 214ddf389..95d52e0c4 100644 --- a/apps/mobile/app/components/delay-layout/index.tsx +++ b/apps/mobile/app/components/delay-layout/index.tsx @@ -30,6 +30,7 @@ interface IDelayLayoutProps extends ViewProps { type?: "default" | "settings"; color?: string; animated?: boolean; + disabled?: boolean; } const placeholder = { @@ -39,13 +40,14 @@ const placeholder = { export default function DelayLayout({ animated = true, + disabled, ...props }: IDelayLayoutProps) { const { colors } = useThemeColors(); const loading = useDelayLayout(props.delay === undefined ? 200 : props.delay); const Placeholder = placeholder[props.type || "default"]; - return loading || props.wait ? ( + return (loading || props.wait) && !disabled ? ( { > diff --git a/apps/mobile/app/screens/settings/components/2fa.tsx b/apps/mobile/app/screens/settings/components/2fa.tsx index 5fd18ac01..3519e9ebd 100644 --- a/apps/mobile/app/screens/settings/components/2fa.tsx +++ b/apps/mobile/app/screens/settings/components/2fa.tsx @@ -64,6 +64,7 @@ import { eCloseSheet } from "../../../utils/events"; import { AppFontSize } from "../../../utils/size"; import { sleep } from "../../../utils/time"; import PaywallSheet from "../../../components/sheets/paywall"; +import LineSeparator from "../../../components/ui/seperator/line-separator"; const mfaMethods: MFAMethod[] = [ { id: "app", @@ -141,7 +142,12 @@ export const MFAMethodsPickerStep = ({ recovery, onSuccess }: MFAStepProps) => { paddingHorizontal: Spacing.LEVEL_3 }} /> - + + + {getMethods().map((item, index, methods) => ( { + if (method.id !== "sms" && user) { + Clipboard.setString( + method.id === "app" + ? authenticatorDetails.sharedKey! + : user.email! + ); + ToastManager.show({ + message: + method.id === "app" + ? strings.codeCopied() + : strings.emailCopied() + }); + } + }} type="transparent" fontFamily="MEDIUM" fontSize={AppFontSize.sm} @@ -479,6 +499,8 @@ export const MFASetup = ({ paddingHorizontal: 0, marginLeft: Spacing.LEVEL_1 }} + icon="copy" + iconFamily="notesnook" title={ sending ? null diff --git a/apps/mobile/app/screens/settings/components/picker/index.tsx b/apps/mobile/app/screens/settings/components/picker/index.tsx index 7289496b7..6ea685671 100644 --- a/apps/mobile/app/screens/settings/components/picker/index.tsx +++ b/apps/mobile/app/screens/settings/components/picker/index.tsx @@ -70,13 +70,13 @@ export function SettingsPicker({ if ((await isFeatureAvailable()) && (await isOptionAvailable(item))) { menuRef.current?.hide(); await updateValue(item); - setCurrentValue(item); + setCurrentValue(getValue()); return; } menuRef.current?.hide(); await updateValue(item); - setCurrentValue(item); + setCurrentValue(getValue()); }; return ( diff --git a/apps/mobile/app/screens/settings/components/picker/pickers.tsx b/apps/mobile/app/screens/settings/components/picker/pickers.tsx index 73a52f98e..a3d9dde2b 100644 --- a/apps/mobile/app/screens/settings/components/picker/pickers.tsx +++ b/apps/mobile/app/screens/settings/components/picker/pickers.tsx @@ -66,7 +66,7 @@ export const FontPicker = createSettingsPicker< }, getItemKey: (item) => item.id, options: getFonts(), - compareValue: (current: any, item) => current.id === item.id, + compareValue: (current: any, item) => current === item.id, isFeatureAvailable: async () => true, isOptionAvailable: async () => true }); diff --git a/apps/mobile/app/screens/settings/components/sound-picker.tsx b/apps/mobile/app/screens/settings/components/sound-picker.tsx index cbdedf2b9..8a115f939 100644 --- a/apps/mobile/app/screens/settings/components/sound-picker.tsx +++ b/apps/mobile/app/screens/settings/components/sound-picker.tsx @@ -59,7 +59,7 @@ const SoundItem = ({ height: 60, borderBottomWidth: 1, borderRadius: 0, - borderBottomColor: colors.primary.border, + borderBottomColor: colors.primary.separator, paddingHorizontal: DefaultAppStyles.GAP }} onPress={async () => { @@ -83,7 +83,13 @@ const SoundItem = ({ }} > { diff --git a/apps/mobile/app/screens/settings/group.tsx b/apps/mobile/app/screens/settings/group.tsx index 6e9e0e989..2119bfbbd 100644 --- a/apps/mobile/app/screens/settings/group.tsx +++ b/apps/mobile/app/screens/settings/group.tsx @@ -80,7 +80,7 @@ const Group = ({ )} - + Platform.OS !== "ios", + useHook: () => + useUserStore( + (state) => + !state.user || + !state.user.subscription || + state.user?.subscription?.plan === SubscriptionPlan.FREE + ), + hidden: (isFreeUser) => + Platform.OS !== "ios" || !(isFreeUser as boolean), isModal: true, modifer: async () => { if (Platform.OS === "android") return; diff --git a/apps/mobile/app/screens/settings/groups/customize.ts b/apps/mobile/app/screens/settings/groups/customize.ts index d4f752c95..7376b7732 100644 --- a/apps/mobile/app/screens/settings/groups/customize.ts +++ b/apps/mobile/app/screens/settings/groups/customize.ts @@ -266,7 +266,8 @@ export const customizeGroup: SettingSection = { name: strings.defaultFontFamily(), description: strings.defaultFontFamilyDesc(), type: "component", - icon: "format-font", + icon: "text-aa", + iconFamily: "notesnook", property: "defaultFontFamily", component: "font-selector" }, @@ -279,7 +280,8 @@ export const customizeGroup: SettingSection = { maxInputValue: 400, step: 1, inputBadgeValue: "px", - icon: "format-size", + icon: "text-aa", + iconFamily: "notesnook", property: "defaultFontSize" }, { @@ -288,7 +290,9 @@ export const customizeGroup: SettingSection = { description: strings.lineHeightDesc(), type: "input-selector", property: "defaultLineHeight", - icon: "format-line-spacing", + icon: "list", + iconSize: 8, + iconFamily: "notesnook", minInputValue: EDITOR_LINE_HEIGHT.MIN, maxInputValue: EDITOR_LINE_HEIGHT.MAX, step: 0.1 @@ -306,7 +310,9 @@ export const customizeGroup: SettingSection = { description: strings.doubleSpacedLinesDesc(), type: "switch", property: "doubleSpacedLines", - icon: "format-line-spacing", + icon: "list", + iconFamily: "notesnook", + iconSize: 8, onChange: () => { ToastManager.show({ heading: strings.lineSpacingChanged(), @@ -335,7 +341,7 @@ export const customizeGroup: SettingSection = { id: "title-format", name: strings.titleFormat(), component: "title-format", - icon: "pencil-ruler", + icon: "article-ny-times", iconFamily: "notesnook", description: strings.titleFormatDesc(), type: "component" diff --git a/apps/mobile/app/screens/settings/groups/productivity.ts b/apps/mobile/app/screens/settings/groups/productivity.ts index b61916e23..9f5438561 100644 --- a/apps/mobile/app/screens/settings/groups/productivity.ts +++ b/apps/mobile/app/screens/settings/groups/productivity.ts @@ -90,8 +90,8 @@ export const productivityGroup: SettingSection = { validators: [ validators.number(), validators.custom((value) => { - return value < 5 - ? strings.valueMustBeGreaterThan("5") + return value < 4 + ? strings.valueMustBeGreaterThan("4") : undefined; }) ], @@ -112,9 +112,7 @@ export const productivityGroup: SettingSection = { component: "sound-picker", icon: "speaker-high", iconFamily: "notesnook", - hidden: () => - Platform.OS === "ios" || - (Platform.OS === "android" && Platform.Version > 25) + hidden: () => Platform.OS === "android" }, { id: "reminder-sound-android", diff --git a/apps/mobile/app/screens/settings/home.tsx b/apps/mobile/app/screens/settings/home.tsx index da1afb42c..caf99d7d4 100644 --- a/apps/mobile/app/screens/settings/home.tsx +++ b/apps/mobile/app/screens/settings/home.tsx @@ -33,6 +33,7 @@ import { useThemeColors } from "@notesnook/theme"; import { Spacing } from "../../common/design/spacing"; import { View } from "react-native"; import LineSeparator from "../../components/ui/seperator/line-separator"; +import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets"; const keyExtractor = (item: SettingSection) => item.id; @@ -40,6 +41,7 @@ const Home = ({ navigation }: NativeStackScreenProps) => { const { colors } = useThemeColors(); + const insets = useGlobalSafeAreaInsets(); const settingsGroups = useSettingsData(); useNavigationFocus(navigation, { onFocus: () => { @@ -86,7 +88,7 @@ const Home = ({ contentContainerStyle={{ gap: Spacing.LEVEL_2, marginTop: Spacing.LEVEL_4, - paddingBottom: 80 + paddingBottom: insets.bottom }} /> diff --git a/apps/mobile/app/screens/settings/restore-backup/index.tsx b/apps/mobile/app/screens/settings/restore-backup/index.tsx index 64592b44d..f0cf5fadf 100644 --- a/apps/mobile/app/screens/settings/restore-backup/index.tsx +++ b/apps/mobile/app/screens/settings/restore-backup/index.tsx @@ -19,325 +19,26 @@ along with this program. If not, see . import { LegendList } from "@legendapp/list"; import { formatBytes, getFormattedDate } from "@notesnook/common"; -import { LegacyBackupFile } from "@notesnook/core"; import { strings } from "@notesnook/intl"; import { useThemeColors } from "@notesnook/theme"; import React, { useEffect, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import RNFetchBlob, { ReactNativeBlobUtilStat } from "react-native-blob-util"; import * as ScopedStorage from "react-native-scoped-storage"; -import { unzip } from "react-native-zip-archive"; -import { DatabaseLogger, db } from "../../../common/database"; +import { Spacing } from "../../../common/design/spacing"; import filesystem from "../../../common/filesystem"; -import { deleteCacheFileByName } from "../../../common/filesystem/io"; -import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils"; -import { presentDialog } from "../../../components/dialog/functions"; -import { - endProgress, - startProgress, - updateProgress -} from "../../../components/dialogs/progress"; import { Button } from "../../../components/ui/button"; -import { - createFormRef, - validators -} from "../../../components/ui/input/form-input"; +import Heading from "../../../components/ui/typography/heading"; import Paragraph from "../../../components/ui/typography/paragraph"; -import { ToastManager } from "../../../services/event-manager"; -import Navigation from "../../../services/navigation"; -import { refreshAllStores } from "../../../stores/create-db-collection-store"; import { useSettingStore } from "../../../stores/use-setting-store"; import { AppFontSize } from "../../../utils/size"; import { DefaultAppStyles } from "../../../utils/styles"; -import Heading from "../../../components/ui/typography/heading"; -import { Spacing } from "../../../common/design/spacing"; +import { restoreBackup } from "./restore-progress"; -type PasswordOrKey = { password?: string; encryptionKey?: string }; - -const RESTORE_CANCELLED = new Error("restore-cancelled"); - -const getPasswordError = (e: unknown): string | undefined => { - const message = e instanceof Error ? e.message : ""; - if (message === "Incorrect password.") return strings.passwordIncorrect(); - if (message === "Invalid encryption key.") - return strings.invalid(strings.encryptionKey()); - return undefined; -}; - -const verifyWithImport = - (importBackup: (passwordOrKey: PasswordOrKey) => Promise) => - async (passwordOrKey: PasswordOrKey): Promise => { - try { - await importBackup(passwordOrKey); - return undefined; - } catch (e) { - const error = getPasswordError(e); - if (error) return error; - throw e; - } - }; - -const withPassword = ( - verify: (passwordOrKey: PasswordOrKey) => Promise -) => { - return new Promise((resolve, reject) => { - const formRef = createFormRef({ password: "" }); - let done = false; - presentDialog({ - context: "local", - title: strings.backupEncrypted(), - paragraph: strings.backupEnterPassword(), - positiveText: strings.restore(), - secureTextEntry: true, - form: { - formRef, - items: [ - { - name: "password", - placeholder: strings.password(), - ref: React.createRef(), - validators: [validators.required(strings.passwordNotEntered())] - } - ], - onFormSubmit: async (form, isEncryptionKey) => { - if (!form.validate()) return false; - const value = form.getValue("password"); - const passwordOrKey: PasswordOrKey = { - encryptionKey: isEncryptionKey ? value : undefined, - password: isEncryptionKey ? undefined : value - }; - try { - const error = await verify(passwordOrKey); - if (error) { - form.setError("password", error); - return false; - } - } catch (e) { - done = true; - reject(e); - return true; - } - done = true; - resolve(passwordOrKey); - return true; - } - }, - onClose: () => { - if (done) return; - resolve({}); - }, - negativeText: strings.cancel(), - check: { - info: strings.useEncryptionKey(), - type: "transparent" - } - }); - }); -}; - -export const restoreBackup = async (options: { - uri: string; - deleteFile?: boolean; -}) => { - try { - if ( - !options.uri.endsWith(".nnbackup") && - !options.uri.endsWith(".nnbackupz") - ) { - throw new Error( - `Invalid backup file selected. Only .nnbackup and .nnbackupz files can be restored.` - ); - } - - const isLegacyBackup = options.uri.endsWith(".nnbackup"); - - startProgress({ - title: strings.restoring(), - paragraph: strings.preparingBackupRestore(), - icon: "arrows-clockwise", - canHideProgress: false - }); - - let filePath = options.uri; - let deleteBackupFile = options.deleteFile; - - if (!isLegacyBackup) { - if (Platform.OS === "android") { - updateProgress({ - progress: strings.copyingBackupFileToCache() - }); - const cacheFile = `file://${RNFetchBlob.fs.dirs.CacheDir}/backup.zip`; - if (await RNFetchBlob.fs.exists(cacheFile)) { - await RNFetchBlob.fs.unlink(cacheFile); - } - - await RNFetchBlob.fs.createFile(cacheFile, "", "utf8"); - if (filePath.startsWith("content://")) { - await copyFileAsync(filePath, cacheFile); - } else { - await RNFetchBlob.fs.cp(filePath, cacheFile); - } - filePath = cacheFile; - deleteBackupFile = true; - } - - const zipOutputFolder = `${cacheDir}/backup_extracted`; - if (await RNFetchBlob.fs.exists(zipOutputFolder)) { - await RNFetchBlob.fs.unlink(zipOutputFolder); - await RNFetchBlob.fs.mkdir(zipOutputFolder); - } - updateProgress({ - progress: strings.extractingFiles() - }); - await unzip(filePath, zipOutputFolder); - - const extractedBackupFiles = await RNFetchBlob.fs.ls(zipOutputFolder); - - const extractedAttachments = extractedBackupFiles.includes("attachments") - ? await RNFetchBlob.fs.ls(`${zipOutputFolder}/attachments`) - : []; - - const attachmentsKeyPath: any = extractedAttachments?.find( - (path) => path === ".attachments_key" - ); - const attachmentsKey = attachmentsKeyPath - ? await JSON.parse( - await RNFetchBlob.fs.readFile( - `${zipOutputFolder}/attachments/${attachmentsKeyPath}`, - "utf8" - ) - ) - : undefined; - - let count = 0; - await db.transaction(async () => { - let passwordOrKey: PasswordOrKey | undefined = undefined; - for (const path of extractedBackupFiles) { - if (path === ".nnbackup" || path === "attachments") continue; - - updateProgress({ - progress: `${strings.restoringBackup()} (${count++}/${ - extractedBackupFiles.length - })` - }); - - const filePath = `${zipOutputFolder}/${path}`; - const data = await RNFetchBlob.fs.readFile(filePath, "utf8"); - const backup = JSON.parse(data); - - const isEncryptedBackup = backup.encrypted; - - const importBackup = (passwordOrKey: PasswordOrKey) => - db.backup.import(backup, { - ...passwordOrKey, - attachmentsKey: attachmentsKey - }); - - if (!isEncryptedBackup) { - await importBackup({}); - continue; - } - - if (!passwordOrKey) { - passwordOrKey = await withPassword(verifyWithImport(importBackup)); - if (!passwordOrKey.encryptionKey && !passwordOrKey.password) { - throw RESTORE_CANCELLED; - } - continue; - } - - await importBackup(passwordOrKey); - } - }); - - await db.initCollections(); - count = 0; - for (const path of extractedAttachments) { - if (path === ".attachments_key") continue; - updateProgress({ - progress: `Restoring attachments (${count++}/${ - extractedAttachments.length - })` - }); - const hash = path; - const attachment = await db.attachments.attachment(hash as string); - if (!attachment) continue; - - await deleteCacheFileByName(hash); - await RNFetchBlob.fs.cp( - `${zipOutputFolder}/attachments/${hash}`, - `${cacheDir}/${hash}` - ); - } - updateProgress({ - progress: strings.cleaningUp() - }); - // Remove files from cache - RNFetchBlob.fs.unlink(zipOutputFolder).catch(() => { - /* empty */ - }); - if (Platform.OS === "android" || deleteBackupFile) { - RNFetchBlob.fs.unlink(filePath).catch(() => { - /* empty */ - }); - } - } else { - updateProgress({ - progress: strings.readingBackupFile() - }); - const rawData = - Platform.OS === "android" - ? await ScopedStorage.readFile(filePath, "utf8") - : await RNFetchBlob.fs.readFile(filePath, "utf8"); - const backup: LegacyBackupFile = JSON.parse(rawData) as LegacyBackupFile; - - const isEncryptedBackup = - typeof backup.data !== "string" && backup.data.cipher; - - await db.transaction(async () => { - const importBackup = (passwordOrKey: PasswordOrKey) => - db.backup.import(backup, passwordOrKey); - - if (!isEncryptedBackup) { - updateProgress({ - progress: strings.restoringBackup() - }); - await importBackup({}); - return; - } - - updateProgress({ - progress: strings.decryptingBackup() - }); - // Prompt for the password and validate it by importing from inside the - // dialog so an incorrect password keeps the dialog open. - const passwordOrKey = await withPassword( - verifyWithImport(importBackup) - ); - if (!passwordOrKey.encryptionKey && !passwordOrKey.password) { - throw RESTORE_CANCELLED; - } - }); - endProgress(); - } - - ToastManager.show({ - heading: strings.backupRestored(), - type: "success" - }); - - await db.initCollections(); - refreshAllStores(); - Navigation.queueRoutesForUpdate(); - endProgress(); - } catch (e) { - endProgress(); - // User dismissed the password prompt: abort silently without an error toast. - if (e === RESTORE_CANCELLED) return; - DatabaseLogger.error(e as Error); - ToastManager.error(e as Error, strings.restoreFailed()); - } -}; +// The restore flow (progress, password, confirmation, error handling) lives in +// ./restore-progress alongside the dialog that drives it. Re-exported here so +// existing importers of "../restore-backup" keep working. +export { restoreBackup } from "./restore-progress"; const BACKUP_FILES_CACHE: (ReactNativeBlobUtilStat | ScopedStorage.FileType)[] = []; @@ -417,7 +118,7 @@ export const RestoreBackup = () => { style={{ marginBottom: Spacing.LEVEL_2 }} - color={colors.secondary.paragraph} + color={colors.primary.accent} size={AppFontSize.sm} fontFamily="MEDIUM" > @@ -489,10 +190,6 @@ const BackupItem = ({ index: number; }) => { const { colors } = useThemeColors(); - - const isLegacyBackup = - item.path?.endsWith(".nnbackup") || - (item as ScopedStorage.FileType).uri?.endsWith(".nnbackup"); const itemName = ( (item as ReactNativeBlobUtilStat).filename || (item as ScopedStorage.FileType).name @@ -540,23 +237,13 @@ const BackupItem = ({ paddingVertical: Spacing.LEVEL_1 }} onPress={() => { - presentDialog({ - title: `${strings.restore()} ${itemName}`, - paragraph: strings.restoreBackupConfirm(), - positiveText: strings.restore(), - negativeText: strings.cancel(), - context: "global", - positivePress: async () => { - setTimeout(() => { - restoreBackup({ - uri: - Platform.OS === "android" - ? (item as ScopedStorage.FileType).uri - : (item as ReactNativeBlobUtilStat).path - }); - }, 500); - return true; - } + restoreBackup({ + confirm: true, + name: itemName, + uri: + Platform.OS === "android" + ? (item as ScopedStorage.FileType).uri + : (item as ReactNativeBlobUtilStat).path }); }} /> diff --git a/apps/mobile/app/screens/settings/restore-backup/restore-progress.tsx b/apps/mobile/app/screens/settings/restore-backup/restore-progress.tsx new file mode 100644 index 000000000..5077bb3cc --- /dev/null +++ b/apps/mobile/app/screens/settings/restore-backup/restore-progress.tsx @@ -0,0 +1,664 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { format, LegacyBackupFile, logManager } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; +import { useThemeColors } from "@notesnook/theme"; +import Clipboard from "@react-native-clipboard/clipboard"; +import React, { useRef, useState, useSyncExternalStore } from "react"; +import { Platform, View } from "react-native"; +import RNFetchBlob from "react-native-blob-util"; +import * as ScopedStorage from "react-native-scoped-storage"; +import { unzip } from "react-native-zip-archive"; +import { Radius, Spacing } from "../../../common/design/spacing"; +import { DatabaseLogger, db } from "../../../common/database"; +import { deleteCacheFileByName } from "../../../common/filesystem/io"; +import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils"; +import DialogButtons from "../../../components/dialog/dialog-buttons"; +import { + hideDialog, + presentDialog +} from "../../../components/dialog/functions"; +import AppIcon from "../../../components/ui/AppIcon"; +import { Button } from "../../../components/ui/button"; +import { Checkbox } from "../../../components/ui/checkbox"; +import { + createFormRef, + FormInput, + validators +} from "../../../components/ui/input/form-input"; +import { ProgressBarComponent } from "../../../components/ui/svg/lazy"; +import Heading from "../../../components/ui/typography/heading"; +import Paragraph from "../../../components/ui/typography/paragraph"; +import { ToastManager } from "../../../services/event-manager"; +import { DDS } from "../../../services/device-detection"; +import Navigation from "../../../services/navigation"; +import { refreshAllStores } from "../../../stores/create-db-collection-store"; +import { getContainerBorder } from "../../../utils/colors"; +import { getElevationStyle } from "../../../utils/elevation"; + +export type PasswordOrKey = { password?: string; encryptionKey?: string }; + +type Mode = "progress" | "password" | "confirm" | "error"; + +type RestoreProgressState = { + mode: Mode; + title?: string; + paragraph?: string; + icon?: string; + progress?: string; + confirmTitle?: string; + confirmParagraph?: string; + errorMessage?: string; +}; + +let state: RestoreProgressState = { mode: "progress" }; +const listeners = new Set<() => void>(); + +let passwordResolve: ((value: PasswordOrKey) => void) | null = null; +let passwordReject: ((reason?: unknown) => void) | null = null; +let passwordVerify: + | ((passwordOrKey: PasswordOrKey) => Promise) + | null = null; +let confirmResolve: ((value: boolean) => void) | null = null; +let retryHandler: (() => void) | null = null; + +function emit() { + listeners.forEach((listener) => listener()); +} + +function setState(patch: Partial) { + state = { ...state, ...patch }; + emit(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot() { + return state; +} + +function clearPending() { + passwordResolve = null; + passwordReject = null; + passwordVerify = null; + confirmResolve = null; + retryHandler = null; +} + +function openDialog() { + presentDialog({ + context: "global", + disableBackdropClosing: true, + onClose: () => { + const resolvePassword = passwordResolve; + const resolveConfirm = confirmResolve; + clearPending(); + resolvePassword?.({}); + resolveConfirm?.(false); + }, + component: () => + }); +} + +const RestoreProgress = { + present(options: { title?: string; paragraph?: string; icon?: string }) { + clearPending(); + state = { mode: "progress", progress: undefined, ...options }; + emit(); + openDialog(); + }, + update(progress: string) { + setState({ progress }); + }, + requestConfirmation(options: { + title?: string; + paragraph?: string; + }): Promise { + return new Promise((resolve) => { + confirmResolve = resolve; + setState({ + mode: "confirm", + confirmTitle: options.title, + confirmParagraph: options.paragraph + }); + }); + }, + requestPassword( + verify: (passwordOrKey: PasswordOrKey) => Promise + ): Promise { + return new Promise((resolve, reject) => { + passwordVerify = verify; + passwordResolve = resolve; + passwordReject = reject; + setState({ mode: "password" }); + }); + }, + showError(error: Error, onRetry: () => void) { + clearPending(); + retryHandler = onRetry; + setState({ + mode: "error", + errorMessage: error.message || strings.somethingWentWrong() + }); + openDialog(); + }, + end() { + clearPending(); + hideDialog(); + } +}; + +const RESTORE_CANCELLED = new Error("restore-cancelled"); + +const getPasswordError = (e: unknown): string | undefined => { + const message = e instanceof Error ? e.message : ""; + if (message === "Incorrect password.") return strings.passwordIncorrect(); + if (message === "Invalid encryption key.") + return strings.invalid(strings.encryptionKey()); + return undefined; +}; + +const verifyWithImport = + (importBackup: (passwordOrKey: PasswordOrKey) => Promise) => + async (passwordOrKey: PasswordOrKey): Promise => { + try { + await importBackup(passwordOrKey); + return undefined; + } catch (e) { + const error = getPasswordError(e); + if (error) return error; + throw e; + } + }; + +export const restoreBackup = async (options: { + uri: string; + deleteFile?: boolean; + confirm?: boolean; + name?: string; +}) => { + const confirmRestore = () => + RestoreProgress.requestConfirmation({ + title: options.name + ? `${strings.restore()} ${options.name}` + : strings.restore(), + paragraph: strings.restoreBackupConfirm() + }); + + try { + if ( + !options.uri.endsWith(".nnbackup") && + !options.uri.endsWith(".nnbackupz") + ) { + throw new Error( + `Invalid backup file selected. Only .nnbackup and .nnbackupz files can be restored.` + ); + } + + const isLegacyBackup = options.uri.endsWith(".nnbackup"); + + RestoreProgress.present({ + title: strings.restoring(), + paragraph: strings.preparingBackupRestore(), + icon: "arrows-clockwise" + }); + + let filePath = options.uri; + let deleteBackupFile = options.deleteFile; + + if (!isLegacyBackup) { + if (Platform.OS === "android") { + RestoreProgress.update(strings.copyingBackupFileToCache()); + const cacheFile = `file://${RNFetchBlob.fs.dirs.CacheDir}/backup.zip`; + if (await RNFetchBlob.fs.exists(cacheFile)) { + await RNFetchBlob.fs.unlink(cacheFile); + } + + await RNFetchBlob.fs.createFile(cacheFile, "", "utf8"); + if (filePath.startsWith("content://")) { + await copyFileAsync(filePath, cacheFile); + } else { + await RNFetchBlob.fs.cp(filePath, cacheFile); + } + filePath = cacheFile; + deleteBackupFile = true; + } + + const zipOutputFolder = `${cacheDir}/backup_extracted`; + if (await RNFetchBlob.fs.exists(zipOutputFolder)) { + await RNFetchBlob.fs.unlink(zipOutputFolder); + await RNFetchBlob.fs.mkdir(zipOutputFolder); + } + RestoreProgress.update(strings.extractingFiles()); + await unzip(filePath, zipOutputFolder); + + const extractedBackupFiles = await RNFetchBlob.fs.ls(zipOutputFolder); + + const extractedAttachments = extractedBackupFiles.includes("attachments") + ? await RNFetchBlob.fs.ls(`${zipOutputFolder}/attachments`) + : []; + + const attachmentsKeyPath: any = extractedAttachments?.find( + (path) => path === ".attachments_key" + ); + const attachmentsKey = attachmentsKeyPath + ? await JSON.parse( + await RNFetchBlob.fs.readFile( + `${zipOutputFolder}/attachments/${attachmentsKeyPath}`, + "utf8" + ) + ) + : undefined; + + let count = 0; + let confirmed = false; + await db.transaction(async () => { + let passwordOrKey: PasswordOrKey | undefined = undefined; + for (const path of extractedBackupFiles) { + if (path === ".nnbackup" || path === "attachments") continue; + + const filePath = `${zipOutputFolder}/${path}`; + const data = await RNFetchBlob.fs.readFile(filePath, "utf8"); + const backup = JSON.parse(data); + + const isEncryptedBackup = backup.encrypted; + + const importBackup = (passwordOrKey: PasswordOrKey) => + db.backup.import(backup, { + ...passwordOrKey, + attachmentsKey: attachmentsKey + }); + + if (!isEncryptedBackup) { + if (options.confirm && !confirmed) { + confirmed = true; + if (!(await confirmRestore())) throw RESTORE_CANCELLED; + } + RestoreProgress.update( + `${strings.restoringBackup()} (${count++}/${ + extractedBackupFiles.length + })` + ); + await importBackup({}); + continue; + } + + RestoreProgress.update( + `${strings.restoringBackup()} (${count++}/${ + extractedBackupFiles.length + })` + ); + + if (!passwordOrKey) { + passwordOrKey = await RestoreProgress.requestPassword( + verifyWithImport(importBackup) + ); + if (!passwordOrKey.encryptionKey && !passwordOrKey.password) { + throw RESTORE_CANCELLED; + } + continue; + } + + await importBackup(passwordOrKey); + } + }); + + await db.initCollections(); + count = 0; + for (const path of extractedAttachments) { + if (path === ".attachments_key") continue; + RestoreProgress.update( + `Restoring attachments (${count++}/${extractedAttachments.length})` + ); + const hash = path; + const attachment = await db.attachments.attachment(hash as string); + if (!attachment) continue; + + await deleteCacheFileByName(hash); + await RNFetchBlob.fs.cp( + `${zipOutputFolder}/attachments/${hash}`, + `${cacheDir}/${hash}` + ); + } + RestoreProgress.update(strings.cleaningUp()); + // Remove files from cache + RNFetchBlob.fs.unlink(zipOutputFolder).catch(() => { + /* empty */ + }); + if (Platform.OS === "android" || deleteBackupFile) { + RNFetchBlob.fs.unlink(filePath).catch(() => { + /* empty */ + }); + } + } else { + RestoreProgress.update(strings.readingBackupFile()); + const rawData = + Platform.OS === "android" + ? await ScopedStorage.readFile(filePath, "utf8") + : await RNFetchBlob.fs.readFile(filePath, "utf8"); + const backup: LegacyBackupFile = JSON.parse(rawData) as LegacyBackupFile; + + const isEncryptedBackup = + typeof backup.data !== "string" && backup.data.cipher; + + await db.transaction(async () => { + const importBackup = (passwordOrKey: PasswordOrKey) => + db.backup.import(backup, passwordOrKey); + + if (!isEncryptedBackup) { + if (options.confirm && !(await confirmRestore())) { + throw RESTORE_CANCELLED; + } + RestoreProgress.update(strings.restoringBackup()); + await importBackup({}); + return; + } + + const passwordOrKey = await RestoreProgress.requestPassword( + verifyWithImport(importBackup) + ); + if (!passwordOrKey.encryptionKey && !passwordOrKey.password) { + throw RESTORE_CANCELLED; + } + }); + } + + ToastManager.show({ + heading: strings.backupRestored(), + type: "success" + }); + + await db.initCollections(); + refreshAllStores(); + Navigation.queueRoutesForUpdate(); + RestoreProgress.end(); + } catch (e) { + if (e === RESTORE_CANCELLED) { + RestoreProgress.end(); + return; + } + DatabaseLogger.error(e as Error); + RestoreProgress.showError(e as Error, () => restoreBackup(options)); + } +}; + +function RestoreProgressDialog() { + const { colors } = useThemeColors(); + const current = useSyncExternalStore(subscribe, getSnapshot); + const formRef = useRef(createFormRef({ password: "" })); + const [isEncryptionKey, setIsEncryptionKey] = useState(false); + + const onSubmitPassword = async () => { + if (!formRef.current.validate()) return; + const value = formRef.current.getValue("password"); + const passwordOrKey: PasswordOrKey = { + encryptionKey: isEncryptionKey ? value : undefined, + password: isEncryptionKey ? undefined : value + }; + + const verify = passwordVerify; + const resolve = passwordResolve; + const reject = passwordReject; + + setState({ mode: "progress", progress: strings.decryptingBackup() }); + + let error: string | undefined; + try { + error = await verify?.(passwordOrKey); + } catch (e) { + clearPending(); + reject?.(e); + return; + } + + if (error) { + formRef.current.setError("password", error); + setState({ mode: "password" }); + return; + } + + clearPending(); + resolve?.(passwordOrKey); + }; + + const onCancelPassword = () => { + const resolve = passwordResolve; + clearPending(); + resolve?.({}); + }; + + const onConfirm = () => { + const resolve = confirmResolve; + confirmResolve = null; + setState({ mode: "progress", progress: strings.restoringBackup() }); + resolve?.(true); + }; + + const onCancelConfirm = () => { + const resolve = confirmResolve; + confirmResolve = null; + resolve?.(false); + }; + + const onRetry = () => { + const retry = retryHandler; + retryHandler = null; + retry?.(); + }; + + const onCloseError = () => { + retryHandler = null; + hideDialog(); + }; + + const onCopyLogs = async () => { + try { + const groups = await logManager?.get(); + const logsText = + groups?.[0]?.logs?.map((log) => format(log)).join("\n") ?? ""; + Clipboard.setString( + `${current.errorMessage ?? ""}\n\n${logsText}`.trim() + ); + ToastManager.show({ + heading: strings.logsCopied(), + type: "success", + context: "local" + }); + } catch (e) { + ToastManager.show({ + heading: strings.failedToCopyToClipboard(), + type: "error", + context: "local" + }); + } + }; + + const isError = current.mode === "error"; + + const title = + current.mode === "password" + ? strings.backupEncrypted() + : current.mode === "confirm" + ? current.confirmTitle + : current.mode === "error" + ? strings.restoreFailed() + : current.title; + + const paragraph = + current.mode === "password" + ? strings.backupEnterPassword() + : current.mode === "confirm" + ? current.confirmParagraph + : current.mode === "error" + ? current.errorMessage + : current.progress || current.paragraph; + + const iconName = + current.mode === "password" + ? "lock" + : current.mode === "error" + ? "warning-circle" + : current.icon || "arrows-clockwise"; + + return ( + + + + + + + + {title} + + + {paragraph} + + + + + {current.mode === "error" ? ( + <> + +