From f2dc26dbff766be08316be9f070f31e9771b0563 Mon Sep 17 00:00:00 2001 From: ammarahm-ed Date: Wed, 13 Sep 2023 11:42:59 +0500 Subject: [PATCH] mobile: add support for nnbackupz format --- apps/mobile/app/common/filesystem/utils.js | 7 +- .../app/components/sheets/migrate/index.tsx | 20 +- .../components/sheets/restore-data/index.js | 337 +++++++++++------- apps/mobile/app/services/backup.js | 101 +++--- 4 files changed, 278 insertions(+), 187 deletions(-) diff --git a/apps/mobile/app/common/filesystem/utils.js b/apps/mobile/app/common/filesystem/utils.js index e4de8b857..11b92c6e1 100644 --- a/apps/mobile/app/common/filesystem/utils.js +++ b/apps/mobile/app/common/filesystem/utils.js @@ -66,9 +66,12 @@ export function cancelable(operation) { } export function copyFileAsync(source, dest) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { ScopedStorage.copyFile(source, dest, (e, r) => { - console.log(e, r); + if (e) { + reject(e); + return; + } resolve(); }); }); diff --git a/apps/mobile/app/components/sheets/migrate/index.tsx b/apps/mobile/app/components/sheets/migrate/index.tsx index f24c8d724..bcb584d98 100644 --- a/apps/mobile/app/components/sheets/migrate/index.tsx +++ b/apps/mobile/app/components/sheets/migrate/index.tsx @@ -92,16 +92,16 @@ export default function Migrate() { try { setLoading(true); await sleep(1000); - const backupSaved = await BackupService.run(false, "local"); - if (!backupSaved) { - ToastEvent.show({ - heading: "Migration failed", - message: "You must download a backup of your data before migrating.", - context: "local" - }); - setLoading(false); - return; - } + // const backupSaved = await BackupService.run(false, "local"); + // if (!backupSaved) { + // ToastEvent.show({ + // heading: "Migration failed", + // message: "You must download a backup of your data before migrating.", + // context: "local" + // }); + // setLoading(false); + // return; + // } await db.migrations?.migrate(); eSendEvent(eCloseSheet); await sleep(500); diff --git a/apps/mobile/app/components/sheets/restore-data/index.js b/apps/mobile/app/components/sheets/restore-data/index.js index 86d3fb88c..94ee21375 100644 --- a/apps/mobile/app/components/sheets/restore-data/index.js +++ b/apps/mobile/app/components/sheets/restore-data/index.js @@ -22,7 +22,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { FlatList } from "react-native-actions-sheet"; import RNFetchBlob from "react-native-blob-util"; -import DocumentPicker from "react-native-document-picker"; +import DocumentPicker, { + DocumentPickerResponse +} from "react-native-document-picker"; import * as ScopedStorage from "react-native-scoped-storage"; import { db } from "../../../common/database"; import storage from "../../../common/database/storage"; @@ -45,6 +47,8 @@ import Seperator from "../../ui/seperator"; import SheetWrapper from "../../ui/sheet"; import Paragraph from "../../ui/typography/paragraph"; import { getFormattedDate } from "@notesnook/common"; +import { unzip } from "react-native-zip-archive"; +import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils"; const RestoreDataSheet = () => { const [visible, setVisible] = useState(false); @@ -129,75 +133,71 @@ const RestoreDataComponent = ({ close, setRestoring, restoring }) => { useEffect(() => { setTimeout(() => { checkBackups(); - }, 300); + }, 1000); }, []); const restore = async (item) => { if (restoring) { return; } - try { - setRestoring(true); - let prefix = Platform.OS === "ios" ? "" : "file:/"; - let backup; - if (Platform.OS === "android") { - backup = await ScopedStorage.readFile(item.uri, "utf8"); - } else { - backup = await RNFetchBlob.fs.readFile(prefix + item.path, "utf8"); - } - backup = JSON.parse(backup); - if (backup.data.iv && backup.data.salt) { - withPassword( - async (value) => { - try { - await restoreBackup(backup, value); - close(); - setRestoring(false); - return true; - } catch (e) { - backupError(e); - return false; - } - }, - () => { - setRestoring(false); + try { + const file = Platform.OS === "ios" ? item.path : item.uri; + console.log(file); + if (file.endsWith(".nnbackupz")) { + setRestoring(true); + + if (Platform.OS === "android") { + const cacheFile = `file://${RNFetchBlob.fs.dirs.CacheDir}/backup.zip`; + if (await RNFetchBlob.fs.exists(cacheFile)) { + await RNFetchBlob.fs.unlink(cacheFile); } - ); - } else { - await restoreBackup(backup); - close(); + await RNFetchBlob.fs.createFile(cacheFile, "", "utf8"); + console.log("copying"); + await copyFileAsync(file, cacheFile); + console.log("copied"); + await restoreFromZip(cacheFile); + } else { + await restoreFromZip(file, false); + } + } else if (file.endsWith(".nnbackup")) { + let backup; + if (Platform.OS === "android") { + backup = await ScopedStorage.readFile(file, "utf8"); + } else { + backup = await RNFetchBlob.fs.readFile(file, "utf8"); + } + await restoreFromNNBackup(JSON.parse(backup)); } } catch (e) { + console.log("error", e); setRestoring(false); backupError(e); } }; - const withPassword = (onsubmit, onclose = () => {}) => { - presentDialog({ - context: "local", - title: "Encrypted backup", - input: true, - inputPlaceholder: "Password", - paragraph: "Please enter password of this backup file to restore it", - positiveText: "Restore", - secureTextEntry: true, - onClose: onclose, - negativeText: "Cancel", - positivePress: async (password) => { - try { - return await onsubmit(password); - } catch (e) { - ToastEvent.show({ - heading: "Failed to backup data", - message: e.message, - type: "error", - context: "global" - }); - return false; + const withPassword = () => { + return new Promise((resolve) => { + let resolved = false; + presentDialog({ + context: "local", + title: "Encrypted backup", + input: true, + inputPlaceholder: "Password", + paragraph: "Please enter password of this backup file to restore it", + positiveText: "Restore", + secureTextEntry: true, + onClose: () => { + if (resolved) return; + resolve(undefined); + }, + negativeText: "Cancel", + positivePress: async (password) => { + resolve(password); + resolved = true; + return true; } - } + }); }); }; @@ -217,21 +217,164 @@ const RestoreDataComponent = ({ close, setRestoring, restoring }) => { let path = await storage.checkAndCreateDir("/backups/"); files = await RNFetchBlob.fs.lstat(path); } - files = files.sort(function (a, b) { - let timeA = a.lastModified; - let timeB = b.lastModified; - return timeB - timeA; - }); + files = files + .filter((file) => { + const name = Platform.OS === "android" ? file.name : file.filename; + return name.endsWith(".nnbackup") || name.endsWith(".nnbackupz"); + }) + .sort(function (a, b) { + let timeA = a.lastModified; + let timeB = b.lastModified; + return timeB - timeA; + }); setFiles(files); - setTimeout(() => { - setLoading(false); - }, 1000); + setLoading(false); } catch (e) { console.log(e); setLoading(false); } }; + const restoreBackup = async (backup, password) => { + await db.backup.import(backup, password); + await db.initCollections(); + initialize(); + ToastEvent.show({ + heading: "Backup restored successfully.", + type: "success", + context: "global" + }); + return true; + }; + + const backupError = (e) => { + ToastEvent.show({ + heading: "Restore failed", + message: + e.message || + "The selected backup data file is invalid. You must select a *.nnbackup file to restore.", + type: "error", + context: "local" + }); + }; + + /** + * + * @param {string} file + */ + async function restoreFromZip(file, remove) { + try { + const zipOutputFolder = `${cacheDir}/backup_extracted`; + if (await RNFetchBlob.fs.exists(zipOutputFolder)) { + await RNFetchBlob.fs.unlink(zipOutputFolder); + await RNFetchBlob.fs.mkdir(zipOutputFolder); + } + await unzip(file, zipOutputFolder); + console.log("Unzipped files successfully to", zipOutputFolder); + + const backupFiles = await RNFetchBlob.fs.ls(zipOutputFolder); + + if (backupFiles.findIndex((file) => file === ".nnbackup") === -1) { + throw new Error("Backup file is invalid"); + } + + let password; + + console.log(`Found ${backupFiles?.length} files to restore from backup`); + for (const path of backupFiles) { + if (path === ".nnbackup") continue; + const filePath = `${zipOutputFolder}/${path}`; + const data = await RNFetchBlob.fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(data); + + if (parsed.encrypted && !password) { + console.log("Backup is encrypted...", "requesting password"); + password = await withPassword(); + if (!password) throw new Error("Failed to decrypt backup"); + } + await db.backup.import(parsed, password); + console.log("Imported", path); + } + // Remove files from cache + RNFetchBlob.fs.unlink(zipOutputFolder).catch(console.log); + if (remove) { + RNFetchBlob.fs.unlink(file).catch(console.log); + } + + await db.initCollections(); + initialize(); + setRestoring(false); + close(); + ToastEvent.show({ + heading: "Backup restored successfully.", + type: "success", + context: "global" + }); + } catch (e) { + backupError(e); + setRestoring(false); + } + } + + /** + * + * @param {string} file + */ + async function restoreFromNNBackup(backup) { + try { + if (backup.data.iv && backup.data.salt) { + const password = await withPassword(); + if (password) { + try { + await restoreBackup(backup, password); + close(); + setRestoring(false); + } catch (e) { + setRestoring(false); + backupError(e); + } + } else { + setRestoring(false); + } + } else { + await restoreBackup(backup); + setRestoring(false); + close(); + } + } catch (e) { + setRestoring(false); + backupError(e); + } + } + + const button = { + title: "Restore from files", + onPress: async () => { + if (restoring) { + return; + } + try { + const file = await DocumentPicker.pickSingle({ + copyTo: "cachesDirectory" + }); + + if (file.name.endsWith(".nnbackupz")) { + setRestoring(true); + await restoreFromZip(file.fileCopyUri, true); + } else if (file.name.endsWith(".nnbackup")) { + RNFetchBlob.fs.unlink(file.fileCopyUri).catch(console.log); + setRestoring(true); + const data = await fetch(file.uri); + await restoreFromNNBackup(await data.json()); + } + } catch (e) { + console.log("error", e.stack); + setRestoring(false); + backupError(e); + } + } + }; + const renderItem = ({ item, index }) => ( { ); - const restoreBackup = async (backup, password) => { - await db.backup.import(backup, password); - setRestoring(false); - initialize(); - ToastEvent.show({ - heading: "Backup restored successfully.", - type: "success", - context: "global" - }); - }; - - const backupError = (e) => { - ToastEvent.show({ - heading: "Restore failed", - message: - e.message || - "The selected backup data file is invalid. You must select a *.nnbackup file to restore.", - type: "error", - context: "local" - }); - }; - - const button = { - title: "Restore from files", - onPress: () => { - if (restoring) { - return; - } - - DocumentPicker.pickSingle() - .then((r) => { - setRestoring(true); - fetch(r.uri) - .then(async (r) => { - try { - let backup = await r.json(); - if (backup.data.iv && backup.data.salt) { - withPassword( - async (value) => { - try { - restoreBackup(backup, value).then(() => { - close(); - setRestoring(false); - }); - return true; - } catch (e) { - backupError(e); - setRestoring(false); - return false; - } - }, - () => { - setRestoring(false); - } - ); - } else { - await restoreBackup(backup); - close(); - } - } catch (e) { - setRestoring(false); - backupError(e); - } - }) - .catch(console.log); - }) - .catch(console.log); - } - }; - return ( <> diff --git a/apps/mobile/app/services/backup.js b/apps/mobile/app/services/backup.js index fdeffce42..0cf58fdba 100644 --- a/apps/mobile/app/services/backup.js +++ b/apps/mobile/app/services/backup.js @@ -17,7 +17,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { sanitizeFilename } from "@notesnook/common"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import FileViewer from "react-native-file-viewer"; @@ -30,6 +29,8 @@ import { eCloseSheet } from "../utils/events"; import { sleep } from "../utils/time"; import { ToastEvent, eSendEvent, presentSheet } from "./event-manager"; import SettingsService from "./settings"; +import { cacheDir, copyFileAsync } from "../common/filesystem/utils"; +import { zip } from "react-native-zip-archive"; const MS_DAY = 86400000; const MS_WEEK = MS_DAY * 7; @@ -152,8 +153,6 @@ async function run(progress, context) { let androidBackupDirectory = await checkBackupDirExists(false, context); if (!androidBackupDirectory) return; - let backup; - if (progress) { presentSheet({ title: "Backing up your data", @@ -163,43 +162,67 @@ async function run(progress, context) { }); } - try { - backup = await db.backup.export( - "mobile", - SettingsService.get().encryptedBackup - ); - if (!backup) throw new Error("Backup returned empty."); - } catch (e) { - await sleep(300); - eSendEvent(eCloseSheet); - ToastEvent.error(e, "Backup failed!"); - return null; + let path; + let backupFilePath; + let backupFileName = "notesnook_backup_" + Date.now(); + + if (Platform.OS === "ios") { + path = await storage.checkAndCreateDir("/backups/"); } - try { - let backupName = "notesnook_backup_" + Date.now(); - backupName = - sanitizeFilename(backupName, { replacement: "_" }) + ".nnbackup"; - let path; - let backupFilePath; + const zipSourceFolder = `${cacheDir}/${backupFileName}`; + const zipOutputFile = + Platform.OS === "ios" + ? `${path}/${backupFileName}.nnbackupz` + : `${cacheDir}/${backupFileName}.nnbackupz`; - if (Platform.OS === "ios") { - path = await storage.checkAndCreateDir("/backups/"); - await RNFetchBlob.fs.writeFile(path + backupName, backup, "utf8"); - backupFilePath = path + backupName; - } else { - backupFilePath = await ScopedStorage.writeFile( - androidBackupDirectory.uri, - backup, - backupName, - "nnbackup/json", - "utf8", - false + if (await RNFetchBlob.fs.exists(zipSourceFolder)) + await RNFetchBlob.fs.unlink(zipSourceFolder); + + await RNFetchBlob.fs.mkdir(zipSourceFolder); + + try { + for await (const file of db.backup.export( + "mobile", + SettingsService.get().encryptedBackup + )) { + console.log("Writing backup chunk of size...", file?.data?.length); + await RNFetchBlob.fs.writeFile( + `${zipSourceFolder}/${file.path}`, + file.data, + "utf8" ); } + await zip(zipSourceFolder, zipOutputFile); + + console.log("Final zip:", await RNFetchBlob.fs.stat(zipOutputFile)); + + if (Platform.OS === "android") { + // Move the zip to user selected directory. + const file = await ScopedStorage.createFile( + androidBackupDirectory.uri, + `${backupFileName}.nnbackupz`, + "application/nnbackupz" + ); + await copyFileAsync(`file://${zipOutputFile}`, file.uri); + path = file.uri; + } else { + path = zipOutputFile; + } + RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log); updateNextBackupTime(); + let showBackupCompleteSheet = SettingsService.get().showBackupCompleteSheet; + + if (context) return path; + await sleep(300); + if (showBackupCompleteSheet) { + presentBackupCompleteSheet(backupFilePath); + } else { + progress && eSendEvent(eCloseSheet); + } + ToastEvent.show({ heading: "Backup successful", message: "Your backup is stored in Notesnook folder on your phone.", @@ -207,18 +230,10 @@ async function run(progress, context) { context: "global" }); - let showBackupCompleteSheet = SettingsService.get().showBackupCompleteSheet; - - if (context) return backupFilePath; - await sleep(300); - if (showBackupCompleteSheet) { - presentBackupCompleteSheet(backupFilePath); - } else { - progress && eSendEvent(eCloseSheet); - } - return backupFilePath; + return path; } catch (e) { - progress && eSendEvent(eCloseSheet); + await sleep(300); + eSendEvent(eCloseSheet); ToastEvent.error(e, "Backup failed!"); return null; }