Files
notesnook/apps/mobile/app/services/backup.ts
Ammar Ahmed b8275f5a3f Fix creating backups ios (#7777)
* mobile: fix copying file from app group during backup

* mobile: catch and show error if copying file fails during backup
2025-03-17 16:24:04 +05:00

421 lines
11 KiB
TypeScript

/*
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 <http://www.gnu.org/licenses/>.
*/
import { sanitizeFilename } from "@notesnook/common";
import { formatDate } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import FileViewer from "react-native-file-viewer";
import * as ScopedStorage from "react-native-scoped-storage";
import Share from "react-native-share";
import { zip } from "react-native-zip-archive";
import { DatabaseLogger, db } from "../common/database";
import filesystem, { FileStorage } from "../common/filesystem";
import { cacheDir, copyFileAsync } from "../common/filesystem/utils";
import { presentDialog } from "../components/dialog/functions";
import {
endProgress,
startProgress,
updateProgress
} from "../components/dialogs/progress";
import { eCloseSheet } from "../utils/events";
import { sleep } from "../utils/time";
import { ToastManager, eSendEvent, presentSheet } from "./event-manager";
import SettingsService from "./settings";
import { getCachePathForFile } from "../common/filesystem/io";
const MS_DAY = 86400000;
const MS_WEEK = MS_DAY * 7;
const MONTH = MS_DAY * 30;
async function getDirectoryAndroid() {
const folder = await ScopedStorage.openDocumentTree(true);
if (!folder) return null;
let subfolder;
if (!folder.name.includes("Notesnook backups")) {
const files = await ScopedStorage.listFiles(folder.uri);
for (const file of files) {
if (file.type === "directory" && file.name === "Notesnook backups") {
subfolder = file;
}
}
if (!subfolder) {
subfolder = await ScopedStorage.createDirectory(
folder.uri,
"Notesnook backups"
);
}
} else {
subfolder = folder;
}
SettingsService.set({
backupDirectoryAndroid: subfolder
});
return subfolder;
}
async function checkBackupDirExists(reset = false, context = "global") {
if (Platform.OS === "ios") return true;
let dir = SettingsService.get().backupDirectoryAndroid;
if (reset) dir = null;
if (dir) {
const allDirs = await ScopedStorage.getPersistedUriPermissions();
let exists = allDirs.findIndex((d) => {
return d === dir?.uri || dir?.uri.includes(d);
}) as number | boolean;
exists = exists !== -1;
dir = exists ? dir : null;
}
if (!dir) {
// eslint-disable-next-line no-async-promise-executor
dir = await new Promise(async (resolve) => {
if (reset) {
resolve(await getDirectoryAndroid());
return;
}
const desc = strings.selectBackupDirDesc(
SettingsService.get().backupDirectoryAndroid?.path || ""
);
presentDialog({
title: strings.selectBackupDir(),
paragraph: desc[0] + " " + desc,
positivePress: async () => {
resolve(await getDirectoryAndroid());
},
onClose: () => {
resolve(null);
},
positiveText: strings.select(),
context: context
});
});
}
return dir;
}
async function presentBackupCompleteSheet(backupFilePath: string) {
presentSheet({
title: strings.backupComplete(),
icon: "cloud-upload",
paragraph: strings.backupSaved(Platform.OS),
actionText: strings.shareBackup(),
actionsArray: [
{
action: () => {
if (Platform.OS === "ios") {
Share.open({
url: backupFilePath,
failOnCancel: false
}).catch(() => {
/* empty */
});
} else {
FileViewer.open(backupFilePath, {
showOpenWithDialog: true,
showAppsSuggestions: true,
shareFile: true
} as any).catch(() => {
/* empty */
});
}
},
actionText: strings.share()
},
{
action: async () => {
eSendEvent(eCloseSheet);
SettingsService.set({
showBackupCompleteSheet: false
});
},
actionText: strings.neverAskAgain(),
type: "secondary"
}
]
});
}
async function updateNextBackupTime(type: "full" | "partial") {
SettingsService.set({
nextBackupRequestTime: Date.now() + 86400000 * 3,
[type === "full" ? "lastFullBackupDate" : "lastBackupDate"]: Date.now()
});
}
let backupRunning = false;
/**
* @param {boolean=} progress
* @param {string=} context
* @returns {Promise<{path?: string, error?: Error, report?: boolean}}>
*/
async function run(
progress = false,
context?: string,
backupType: "full" | "partial" = "partial"
): Promise<{
path?: string;
error?: Error;
report?: boolean;
}> {
if (backupRunning) {
if (progress) {
startProgress({
title: strings.backingUpData(backupType),
paragraph: strings.backupDataDesc(),
progress: "Backup in progress...",
canHideProgress: true
});
}
return {};
}
backupRunning = true;
const androidBackupDirectory = (await checkBackupDirExists(
false,
context
)) as ScopedStorage.FileType;
if (!androidBackupDirectory)
return {
error: new Error(strings.backupDirectoryNotSelected()),
report: false
};
let path;
if (Platform.OS === "ios") {
path = await filesystem.checkAndCreateDir("/backups");
}
const backupFileName = sanitizeFilename(
`${formatDate(Date.now(), {
type: "date-time",
dateFormat: "YYYY-MM-DD",
timeFormat: "24-hour"
})}-${new Date().getSeconds()}${backupType === "full" ? "-full" : ""}`,
{ replacement: "-" }
);
if (progress) {
startProgress({
title: strings.backingUpData(backupType),
paragraph: strings.backupDataDesc(),
progress: "Preparing backup...",
canHideProgress: true
});
}
const zipSourceFolder = `${cacheDir}/backup_temp`;
const zipOutputFile =
Platform.OS === "ios"
? `${path}/${backupFileName}.nnbackupz`
: `${cacheDir}/${backupFileName}.nnbackupz`;
await RNFetchBlob.fs.unlink(zipSourceFolder).catch(() => {
/* empty */
});
await RNFetchBlob.fs.mkdir(zipSourceFolder);
const attachmentsDir = zipSourceFolder + "/attachments";
if (backupType === "full") {
await RNFetchBlob.fs.mkdir(attachmentsDir);
}
try {
const user = await db.user.getUser();
DatabaseLogger.info(`Backup started: ${backupType}`);
for await (const file of db.backup.export({
type: "mobile",
encrypt: SettingsService.get().encryptedBackup && !!user,
mode: backupType
})) {
if (file.type === "file") {
updateProgress({
progress: `Writing backup chunk of size... ${file?.data?.length}`
});
await RNFetchBlob.fs.writeFile(
`${zipSourceFolder}/${file.path}`,
file.data,
"utf8"
);
} else if (file.type === "attachment") {
updateProgress({
progress: `Saving attachments in backup... ${file.hash}`
});
if (await FileStorage.exists(file.hash)) {
await RNFetchBlob.fs
.cp(
await getCachePathForFile(file.hash),
`${attachmentsDir}/${file.hash}`
)
.catch((e) =>
DatabaseLogger.error(e, "Error saving attachment to backup file")
);
}
}
}
DatabaseLogger.info(`Backup complete: ${backupType}. Creating zip file...`);
updateProgress({
progress: "Creating backup zip file..."
});
await zip(zipSourceFolder, zipOutputFile);
DatabaseLogger.info(`Backup zip file created: ${zipOutputFile}`);
if (Platform.OS === "android") {
// Move the zip to user selected directory.
const file = await ScopedStorage.createFile(
androidBackupDirectory?.uri,
`${backupFileName}.nnbackupz`,
"application/nnbackupz"
);
DatabaseLogger.info("Copying backup file to user selected directory...");
await copyFileAsync(`file://${zipOutputFile}`, file.uri);
DatabaseLogger.info("Backup saved to user selected directory.");
path = file.uri;
} else {
path = zipOutputFile;
}
cleanupAfterBackup(zipSourceFolder, zipOutputFile);
updateNextBackupTime(backupType || "partial");
if (progress) {
endProgress();
}
const canShowCompletionStatus =
progress && SettingsService.get().showBackupCompleteSheet;
backupRunning = false;
if (context) {
return {
path: path
};
}
await sleep(300);
if (canShowCompletionStatus) {
presentBackupCompleteSheet(path);
}
ToastManager.show({
heading: strings.backupSuccess(),
type: "success",
context: "global"
});
return {
path: path
};
} catch (e) {
backupRunning = false;
ToastManager.error(e as Error, strings.backupFailed(), context || "global");
if (
(e as Error)?.message?.includes("android.net.Uri") &&
androidBackupDirectory
) {
SettingsService.setProperty("backupDirectoryAndroid", null);
return run(progress, context, backupType);
}
cleanupAfterBackup(zipSourceFolder, zipOutputFile);
DatabaseLogger.error(e);
if (progress) {
endProgress();
}
return {
error: e as Error,
report: true
};
}
}
function cleanupAfterBackup(zipSourceFolder: string, zipOutputFile: string) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(() => {
/* empty */
});
if (Platform.OS === "android") {
RNFetchBlob.fs.unlink(zipOutputFile).catch(() => {
/* empty */
});
}
}
async function checkBackupRequired(
type: "daily" | "off" | "useroff" | "weekly" | "monthly" | "never",
lastBackupDateType: "lastBackupDate" | "lastFullBackupDate" = "lastBackupDate"
) {
if (type === "off" || type === "useroff" || type === "never" || !type) return;
const now = Date.now();
const lastBackupDate = SettingsService.getProperty(lastBackupDateType) as
| number
| "never";
if (
lastBackupDate === undefined ||
lastBackupDate === "never" ||
lastBackupDate === 0
) {
return true;
}
if (type === "daily" && lastBackupDate + MS_DAY < now) {
DatabaseLogger.info(`Daily backup started: ${type}`);
return true;
} else if (type === "weekly" && lastBackupDate + MS_WEEK < now) {
DatabaseLogger.info(`Weekly backup started: ${type}`);
return true;
} else if (type === "monthly" && lastBackupDate + MONTH < now) {
DatabaseLogger.info(`Monthly backup started: ${type}`);
return true;
}
return false;
}
const checkAndRun = async () => {
const settings = SettingsService.get();
if (await checkBackupRequired(settings?.reminder)) {
try {
await run();
} catch (e) {}
}
};
const BackupService = {
checkBackupRequired,
run,
checkAndRun,
getDirectoryAndroid,
checkBackupDirExists
};
export default BackupService;