mobile: add support for offline mode, backup with attachments

This commit is contained in:
Ammar Ahmed
2024-07-31 15:44:18 +05:00
committed by Abdullah Atta
parent b6021dc76b
commit d12ed967e3
24 changed files with 1068 additions and 715 deletions

View File

@@ -21,15 +21,15 @@ import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import {
decrypt,
decryptMulti,
deriveCryptoKey,
encrypt,
encryptMulti,
generateCryptoKey,
getCryptoKey,
getRandomBytes,
hash,
removeCryptoKey,
decryptMulti,
encryptMulti
removeCryptoKey
} from "./encryption";
import { MMKV } from "./mmkv";

View File

@@ -259,15 +259,6 @@ export default async function downloadAttachment(
});
}
if (
attachment.dateUploaded &&
!isImage(attachment.mimeType) &&
!isDocument(attachment.mimeType)
) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}`)
.catch(console.log);
}
if (Platform.OS === "ios" && !options.cache) {
fileUri = folder.uri + `/${filename}`;
}
@@ -287,7 +278,6 @@ export default async function downloadAttachment(
return fileUri;
} catch (e) {
DatabaseLogger.error(e);
if (attachment.dateUploaded) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}`)
@@ -296,6 +286,7 @@ export default async function downloadAttachment(
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.hash}_dcache`)
.catch(console.log);
}
DatabaseLogger.error(e);
useAttachmentStore.getState().remove(attachment.hash);
if (options.throwError) {
throw e;

View File

@@ -90,7 +90,7 @@ export async function downloadFile(filename, data, cancelToken) {
useAttachmentStore.getState().remove(filename);
return status >= 200 && status < 300;
} catch (e) {
if (e.message !== "canceled") {
if (e.message !== "canceled" && !e.message.includes("NoSuchKey")) {
ToastManager.show({
heading: "Error downloading file",
message: e.message,

View File

@@ -24,7 +24,10 @@ import {
exists,
readEncrypted,
writeEncryptedBase64,
hashBase64
hashBase64,
clearCache,
deleteCacheFileByName,
deleteCacheFileByPath
} from "./io";
import { uploadFile } from "./upload";
import { cancelable } from "./utils";
@@ -39,5 +42,8 @@ export default {
exists,
clearFileStorage,
getUploadedFileSize,
checkAttachment
checkAttachment,
clearCache,
deleteCacheFileByName,
deleteCacheFileByPath
};

View File

@@ -158,6 +158,30 @@ export async function migrateFilesFromCache() {
}
}
export async function clearCache() {
const files = await RNFetchBlob.fs.ls(cacheDir);
for (const file of files) {
await RNFetchBlob.fs.unlink(file).catch(console.log);
}
}
export async function deleteCacheFileByPath(path) {
await RNFetchBlob.fs.unlink(path).catch(console.log);
}
export async function deleteCacheFileByName(name) {
await RNFetchBlob.fs.unlink(`${cacheDir}/${name}`).catch(console.log);
}
export async function deleteDCacheFiles() {
const files = await RNFetchBlob.fs.ls(cacheDir);
for (const file of files) {
if (file.includes("_dcache")) {
await RNFetchBlob.fs.unlink(file).catch(console.log);
}
}
}
const ABYTES = 17;
export async function exists(filename) {
let path = `${cacheDir}/${filename}`;

View File

@@ -117,9 +117,6 @@ export async function uploadFile(filename, data, cancelToken) {
);
let attachment = await db.attachments.attachment(filename);
if (!attachment) return result;
if (!isImage(attachment.mimeType) && !isDocument(attachment.mimeType)) {
RNFetchBlob.fs.unlink(`${cacheDir}/${filename}`).catch(console.log);
}
} else {
const fileInfo = await RNFetchBlob.fs.stat(uploadFilePath);
throw new Error(

View File

@@ -23,6 +23,7 @@ import { AnnouncementDialog } from "../announcements";
import AuthModal from "../auth/auth-modal";
import { SessionExpired } from "../auth/session-expired";
import { Dialog } from "../dialog";
import { AppLockPassword } from "../dialogs/applock-password";
import JumpToSectionDialog from "../dialogs/jump-to-section";
import { LoadingDialog } from "../dialogs/loading";
import PDFPreview from "../dialogs/pdf-preview";
@@ -35,8 +36,7 @@ import { Expiring } from "../premium/expiring";
import SheetProvider from "../sheet-provider";
import RateAppSheet from "../sheets/rate-app";
import RecoveryKeySheet from "../sheets/recovery-key";
import RestoreDataSheet from "../sheets/restore-data";
import { AppLockPassword } from "../dialogs/applock-password";
import Progress from "../dialogs/progress";
const DialogProvider = () => {
const { colors } = useThemeColors();
@@ -52,7 +52,6 @@ const DialogProvider = () => {
<RecoveryKeySheet colors={colors} />
<SheetProvider />
<SheetProvider context="sync_progress" />
<RestoreDataSheet />
<ResultDialog />
<VaultDialog colors={colors} />
<RateAppSheet />
@@ -62,6 +61,7 @@ const DialogProvider = () => {
<SessionExpired />
<PDFPreview />
<JumpToSectionDialog />
<Progress />
</>
);
};

View File

@@ -38,15 +38,18 @@ const DialogContainer = ({
return (
<View
{...restProps}
style={{
...getElevationStyle(5),
width: width || DDS.isTab ? 500 : "85%",
maxHeight: height || 450,
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 12,
...getContainerBorder(colors.secondary.background, 0.8, 0.05)
}}
style={[
style,
{
...getElevationStyle(5),
width: width || DDS.isTab ? 500 : "85%",
maxHeight: height || 450,
borderRadius: 10,
backgroundColor: colors.primary.background,
paddingTop: 12,
...getContainerBorder(colors.secondary.background, 0.8, 0.05)
}
]}
/>
);
};

View File

@@ -25,7 +25,7 @@ type DialogInfo = {
paragraph?: string;
positiveText?: string;
negativeText?: string;
positivePress?: (value: any) => void;
positivePress?: (...args: any[]) => void;
onClose?: () => void;
positiveType?:
| "transparent"
@@ -45,6 +45,10 @@ type DialogInfo = {
context: "global" | "local" | (string & {});
secureTextEntry?: boolean;
keyboardType?: string;
check?: {
info: string;
type: string;
};
};
export function presentDialog(data: Partial<DialogInfo>): void {

View File

@@ -44,6 +44,7 @@ import { ProgressBarComponent } from "../../ui/svg/lazy";
import Paragraph from "../../ui/typography/paragraph";
import { sleep } from "../../../utils/time";
import { MMKV } from "../../../common/database/mmkv";
import { deleteCacheFileByPath } from "../../../common/filesystem/io";
const WIN_WIDTH = Dimensions.get("window").width;
const WIN_HEIGHT = Dimensions.get("window").height;
@@ -124,6 +125,7 @@ const PDFPreview = () => {
);
const close = () => {
deleteCacheFileByPath(pdfSource);
setPDFSource(null);
setVisible(false);
setPassword("");

View File

@@ -0,0 +1,182 @@
/*
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 { useThemeColors } from "@notesnook/theme";
import React, { useEffect } from "react";
import { View } from "react-native";
import { eSendEvent, eSubscribeEvent } from "../../../services/event-manager";
import { SIZE } from "../../../utils/size";
import { Dialog } from "../../dialog";
import BaseDialog from "../../dialog/base-dialog";
import DialogContainer from "../../dialog/dialog-container";
import { Button } from "../../ui/button";
import { ProgressBarComponent } from "../../ui/svg/lazy";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
export type ProgressOptions = {
progress?: string;
cancelCallback?: () => void;
title?: string;
paragraph?: string;
};
export const PROGRESS_EVENTS = {
start: "startProgress",
end: "endProgress",
update: "updateProgress"
};
export default function Progress() {
const { colors } = useThemeColors();
const [progress, setProgress] = React.useState<string | undefined>();
const [visible, setVisible] = React.useState(false);
const cancelCallback = React.useRef<() => void>();
const [data, setData] = React.useState<{
title?: string;
paragraph?: string;
}>();
useEffect(() => {
const events = [
eSubscribeEvent(PROGRESS_EVENTS.start, (options: ProgressOptions) => {
setProgress(options.progress);
setVisible(true);
cancelCallback.current = options.cancelCallback;
setData({
title: options.title,
paragraph: options.paragraph
});
}),
eSubscribeEvent(PROGRESS_EVENTS.end, () => {
setProgress(undefined);
setVisible(false);
setData(undefined);
cancelCallback.current?.();
cancelCallback.current = undefined;
}),
eSubscribeEvent(PROGRESS_EVENTS.update, (options: ProgressOptions) => {
setProgress(options.progress);
if (options.cancelCallback) {
cancelCallback.current = options.cancelCallback;
}
if (options.title) {
setData({
title: options.title
});
}
if (options.paragraph) {
setData({
paragraph: options.paragraph
});
}
})
];
return () => {
events.forEach((event) => event?.unsubscribe());
cancelCallback.current = undefined;
};
}, []);
return !visible ? null : (
<BaseDialog visible>
<DialogContainer
style={{
paddingHorizontal: 12,
paddingBottom: 10
}}
>
<Dialog context="local" />
<View
style={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
gap: 10,
paddingBottom: 20
}}
>
<View
style={{
flexDirection: "row",
width: 100,
paddingTop: 20
}}
>
<ProgressBarComponent
height={5}
width={100}
animated={true}
useNativeDriver
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
<Heading
style={{
textAlign: "center"
}}
color={colors.primary.paragraph}
size={SIZE.lg}
>
{data?.title}
</Heading>
<Paragraph
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
>
{progress ? progress : data?.paragraph}
</Paragraph>
<Button
title={cancelCallback.current ? "Cancel" : "Hide"}
type="secondaryAccented"
onPress={() => {
if (cancelCallback.current) {
cancelCallback.current?.();
}
setVisible(false);
setProgress(undefined);
setData(undefined);
}}
width="100%"
/>
</View>
</DialogContainer>
</BaseDialog>
);
}
export function startProgress(options: ProgressOptions) {
eSendEvent(PROGRESS_EVENTS.start, options);
}
export function endProgress() {
eSendEvent(PROGRESS_EVENTS.end);
}
export function updateProgress(options: ProgressOptions) {
eSendEvent(PROGRESS_EVENTS.update, options);
}

View File

@@ -1,600 +0,0 @@
/*
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 { getFormattedDate } from "@notesnook/common";
import { EVENTS } from "@notesnook/core/dist/common";
import { useThemeColors } from "@notesnook/theme";
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 * as ScopedStorage from "react-native-scoped-storage";
import { unzip } from "react-native-zip-archive";
import { db } from "../../../common/database";
import storage from "../../../common/database/storage";
import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils";
import {
ToastManager,
eSubscribeEvent,
eUnSubscribeEvent
} from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import SettingsService from "../../../services/settings";
import { refreshAllStores } from "../../../stores/create-db-collection-store";
import { eCloseRestoreDialog, eOpenRestoreDialog } from "../../../utils/events";
import { SIZE } from "../../../utils/size";
import { Dialog } from "../../dialog";
import DialogHeader from "../../dialog/dialog-header";
import { presentDialog } from "../../dialog/functions";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import Seperator from "../../ui/seperator";
import SheetWrapper from "../../ui/sheet";
import Paragraph from "../../ui/typography/paragraph";
import { useUserStore } from "../../../stores/use-user-store";
const RestoreDataSheet = () => {
const [visible, setVisible] = useState(false);
const [restoring, setRestoring] = useState(false);
const sheet = useRef();
useEffect(() => {
const open = async () => {
setVisible(true);
setTimeout(() => {
sheet.current?.show();
}, 1);
};
eSubscribeEvent(eOpenRestoreDialog, open);
eSubscribeEvent(eCloseRestoreDialog, close);
return () => {
eUnSubscribeEvent(eOpenRestoreDialog, open);
eUnSubscribeEvent(eCloseRestoreDialog, close);
};
}, [close]);
const close = useCallback(() => {
if (restoring) {
showIsWorking();
return;
}
sheet.current?.hide();
setTimeout(() => {
setVisible(false);
}, 150);
}, [restoring]);
const showIsWorking = () => {
ToastManager.show({
heading: "Restoring Backup",
message: "Your backup data is being restored. please wait.",
type: "error",
context: "local"
});
};
return !visible ? null : (
<SheetWrapper
fwdRef={sheet}
gestureEnabled={!restoring}
closeOnTouchBackdrop={!restoring}
onClose={() => {
setVisible(false);
close();
}}
>
<RestoreDataComponent
close={close}
restoring={restoring}
setRestoring={setRestoring}
actionSheetRef={sheet}
/>
<Toast context="local" />
</SheetWrapper>
);
};
export default RestoreDataSheet;
const RestoreDataComponent = ({ close, setRestoring, restoring }) => {
const { colors } = useThemeColors();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [backupDirectoryAndroid, setBackupDirectoryAndroid] = useState(false);
const [progress, setProgress] = useState();
useEffect(() => {
const subscription = db.eventManager.subscribe(
EVENTS.migrationProgress,
(progress) => {
setProgress(progress);
}
);
return () => {
subscription?.unsubscribe();
};
}, []);
useEffect(() => {
setTimeout(() => {
checkBackups();
}, 1000);
}, []);
const restore = async (item) => {
if (restoring) {
return;
}
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);
}
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 = () => {
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",
positiveText: "Restore",
secureTextEntry: true,
onClose: () => {
if (resolved) return;
resolve(undefined);
},
negativeText: "Cancel",
positivePress: async (password, isEncryptionKey) => {
resolve({
value: password,
isEncryptionKey
});
resolved = true;
return true;
},
check: {
info: "Use encryption key",
type: "transparent"
}
});
});
};
const checkBackups = async () => {
try {
let files = [];
if (Platform.OS === "android") {
let backupDirectory = SettingsService.get().backupDirectoryAndroid;
if (backupDirectory) {
setBackupDirectoryAndroid(backupDirectory);
files = await ScopedStorage.listFiles(backupDirectory.uri);
} else {
setLoading(false);
return;
}
} else {
let path = await storage.checkAndCreateDir("/backups/");
files = await RNFetchBlob.fs.lstat(path);
}
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);
setLoading(false);
} catch (e) {
console.log(e);
setLoading(false);
}
};
const restoreBackup = async (backup, password, key) => {
await db.transaction(async () => {
await db.backup.import(backup, password, key);
});
await db.initCollections();
refreshAllStores();
ToastManager.show({
heading: "Backup restored successfully.",
type: "success",
context: "global"
});
Navigation.queueRoutesForUpdate();
return true;
};
const backupError = (e) => {
console.log(e.stack);
ToastManager.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");
// }
await db.transaction(async () => {
let password;
let key;
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");
const { value, isEncryptionKey } = await withPassword();
if (isEncryptionKey) {
key = value;
} else {
password = value;
}
if (!password && !key) throw new Error("Failed to decrypt backup");
}
await db.backup.import(parsed, password, key);
console.log("Imported", path);
}
});
// Remove files from cache
RNFetchBlob.fs.unlink(zipOutputFolder).catch(console.log);
if (remove) {
RNFetchBlob.fs.unlink(file).catch(console.log);
}
refreshAllStores();
Navigation.queueRoutesForUpdate();
setRestoring(false);
close();
ToastManager.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 { value, isEncryptionKey } = await withPassword();
let key;
let password;
if (isEncryptionKey) {
key = value;
} else {
password = value;
}
if (key || password) {
try {
await restoreBackup(backup, password, key);
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 {
useUserStore.setState({
disableAppLockRequests: true
});
console.log("disabled...");
const file = await DocumentPicker.pickSingle({
copyTo: "cachesDirectory"
});
setTimeout(() => {
useUserStore.setState({
disableAppLockRequests: false
});
}, 1000);
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);
setTimeout(() => {
useUserStore.setState({
disableAppLockRequests: false
});
}, 1000);
setRestoring(false);
backupError(e);
}
}
};
const renderItem = ({ item, index }) => (
<View
style={{
minHeight: 50,
justifyContent: "space-between",
alignItems: "center",
width: "100%",
borderRadius: 0,
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: colors.primary.border
}}
>
<View
style={{
maxWidth: "75%"
}}
>
<Paragraph size={SIZE.sm} style={{ width: "100%", maxWidth: "100%" }}>
{getFormattedDate(item?.lastModified * 1)}
</Paragraph>
<Paragraph size={SIZE.xs}>
{(item.filename || item.name).replace(".nnbackup", "")}
</Paragraph>
</View>
<Button
title="Restore"
height={30}
type="accent"
style={{
borderRadius: 100,
paddingHorizontal: 12
}}
fontSize={SIZE.sm - 1}
onPress={() => restore(item, index)}
/>
</View>
);
return (
<>
<View>
<Dialog context="local" />
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 8,
paddingRight: 8,
alignItems: "center",
paddingTop: restoring ? 8 : 0
}}
>
<DialogHeader
title="Backups"
paragraph={`All the backups are stored in ${
Platform.OS === "ios"
? "File Manager/Notesnook/Backups"
: "selected backups folder."
}`}
button={button}
/>
</View>
<Seperator half />
<FlatList
ListEmptyComponent={
!restoring ? (
loading ? (
<View
style={{
justifyContent: "center",
alignItems: "center",
height: 100
}}
>
<ActivityIndicator
color={colors.primary.accent}
size={SIZE.lg}
/>
</View>
) : (
<View
style={{
justifyContent: "center",
alignItems: "center",
height: 100
}}
>
{Platform.OS === "android" && !backupDirectoryAndroid ? (
<>
<Button
title="Select backups folder"
icon="folder"
onPress={async () => {
let folder = await ScopedStorage.openDocumentTree(
true
);
let subfolder;
if (folder.name !== "Notesnook backups") {
subfolder = await ScopedStorage.createDirectory(
folder.uri,
"Notesnook backups"
);
} else {
subfolder = folder;
}
SettingsService.set({
backupDirectoryAndroid: subfolder
});
setBackupDirectoryAndroid(subfolder);
setLoading(true);
checkBackups();
}}
style={{
marginTop: 10,
paddingHorizontal: 12
}}
height={30}
width={null}
/>
<Paragraph
style={{
textAlign: "center",
marginTop: 5
}}
size={SIZE.xs}
textBreakStrategy="balanced"
color={colors.secondary.paragraph}
>
Select the folder that includes your backup files to
list them here.
</Paragraph>
</>
) : (
<Paragraph color={colors.secondary.paragraph}>
No backups found
</Paragraph>
)}
</View>
)
) : (
<View
style={{
justifyContent: "center",
alignItems: "center",
height: 200
}}
>
<ActivityIndicator color={colors.primary.accent} />
<Paragraph color={colors.secondary.paragraph}>
Restoring {progress ? progress?.collection : null}
{progress ? `(${progress.current}/${progress.total}) ` : null}
...Please wait.
</Paragraph>
</View>
)
}
keyExtractor={(item) => item.name || item.filename}
style={{
paddingHorizontal: 12
}}
data={restoring || loading ? [] : files}
renderItem={renderItem}
ListFooterComponent={
restoring || loading || files.length === 0 ? null : (
<View
style={{
height: 200
}}
/>
)
}
/>
</View>
</>
);
};

View File

@@ -465,15 +465,32 @@ export const useAppEvents = () => {
const user = await db.user.getUser();
if (PremiumService.get() && user) {
if (
await BackupService.checkBackupRequired(SettingsService.get().reminder)
) {
if (
!SettingsService.get().backupDirectoryAndroid &&
Platform.OS === "android"
)
return;
sleep(2000).then(() => BackupService.run());
}
!SettingsService.get().backupDirectoryAndroid &&
Platform.OS === "android"
)
return;
const partialBackup = await BackupService.checkBackupRequired(
SettingsService.get().reminder,
"lastBackupDate"
);
const fullBackup = await BackupService.checkBackupRequired(
SettingsService.get().fullBackupReminder,
"lastFullBackupDate"
);
sleep(2000).then(() => {
if (partialBackup) {
BackupService.run().then(() => {
if (fullBackup) {
eSendEvent(eCloseSheet);
BackupService.run(true, undefined, "full");
}
});
} else if (fullBackup) {
BackupService.run(true, undefined, "full");
}
});
}
}, []);

View File

@@ -32,9 +32,11 @@ import {
TimeFormatPicker,
TrashIntervalPicker,
BackupReminderPicker,
ApplockTimerPicker
ApplockTimerPicker,
BackupWithAttachmentsReminderPicker
} from "./picker/pickers";
import ThemeSelector from "./theme-selector";
import { RestoreBackup } from "./restore-backup";
export const components: { [name: string]: ReactElement } = {
colorpicker: <AccentColorPicker />,
@@ -51,5 +53,7 @@ export const components: { [name: string]: ReactElement } = {
"date-format-selector": <DateFormatPicker />,
"time-format-selector": <TimeFormatPicker />,
"theme-selector": <ThemeSelector />,
"applock-timer": <ApplockTimerPicker />
"applock-timer": <ApplockTimerPicker />,
autobackupsattachments: <BackupWithAttachmentsReminderPicker />,
backuprestore: <RestoreBackup />
};

View File

@@ -140,6 +140,29 @@ export const BackupReminderPicker = createSettingsPicker({
}
});
export const BackupWithAttachmentsReminderPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.reminder,
updateValue: (item) => {
SettingsService.set({ fullBackupReminder: item });
},
formatValue: (item) => {
return item.slice(0, 1).toUpperCase() + item.slice(1);
},
getItemKey: (item) => item,
options: ["never", "weekly", "monthly"],
compareValue: (current, item) => current === item,
premium: true,
requiresVerification: () => {
return (
!useSettingStore.getState().settings.encryptedBackup &&
useUserStore.getState().user
);
},
onCheckOptionIsPremium: (item) => {
return item !== "never";
}
});
export const ApplockTimerPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.appLockTimer,
updateValue: (item) => {

View File

@@ -0,0 +1,590 @@
/*
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 Sodium from "@ammarahmed/react-native-sodium";
import { getFormattedDate } from "@notesnook/common";
import { LegacyBackupFile } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, FlatList, Platform, View } from "react-native";
import RNFetchBlob, { ReactNativeBlobUtilStat } from "react-native-blob-util";
import DocumentPicker from "react-native-document-picker";
import * as ScopedStorage from "react-native-scoped-storage";
import { unzip } from "react-native-zip-archive";
import { DatabaseLogger, db } from "../../../common/database";
import storage from "../../../common/database/storage";
import { deleteCacheFileByName } from "../../../common/filesystem/io";
import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils";
import { Dialog } from "../../../components/dialog";
import BaseDialog from "../../../components/dialog/base-dialog";
import DialogContainer from "../../../components/dialog/dialog-container";
import { presentDialog } from "../../../components/dialog/functions";
import { Button } from "../../../components/ui/button";
import { ProgressBarComponent } from "../../../components/ui/svg/lazy";
import Heading from "../../../components/ui/typography/heading";
import Paragraph from "../../../components/ui/typography/paragraph";
import { SectionItem } from "../../../screens/settings/section-item";
import { ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import SettingsService from "../../../services/settings";
import { refreshAllStores } from "../../../stores/create-db-collection-store";
import { useUserStore } from "../../../stores/use-user-store";
import { SIZE } from "../../../utils/size";
type PasswordOrKey = { password?: string; encryptionKey?: string };
const withPassword = () => {
return new Promise<PasswordOrKey>((resolve) => {
let resolved = false;
presentDialog({
context: "local",
title: "Encrypted backup",
input: true,
inputPlaceholder: "Password",
paragraph: "Please enter password of this backup file",
positiveText: "Restore",
secureTextEntry: true,
onClose: () => {
if (resolved) return;
resolve({});
},
negativeText: "Cancel",
positivePress: async (password, isEncryptionKey) => {
resolve({
encryptionKey: isEncryptionKey ? password : undefined,
password: isEncryptionKey ? undefined : password
});
resolved = true;
return true;
},
check: {
info: "Use encryption key",
type: "transparent"
}
});
});
};
const restoreBackup = async (options: {
uri: string;
deleteFile?: boolean;
updateProgress: (progress?: string) => void;
}) => {
try {
const isLegacyBackup = options.uri.endsWith(".nnbackup");
options.updateProgress("Preparing to restore backup file...");
let filePath = options.uri;
if (!isLegacyBackup) {
if (Platform.OS === "android") {
options.updateProgress(`Copying backup file to cache...`);
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");
await copyFileAsync(filePath, cacheFile);
filePath = cacheFile;
}
const zipOutputFolder = `${cacheDir}/backup_extracted`;
if (await RNFetchBlob.fs.exists(zipOutputFolder)) {
await RNFetchBlob.fs.unlink(zipOutputFolder);
await RNFetchBlob.fs.mkdir(zipOutputFolder);
}
options.updateProgress(`Extracting files from backup...`);
await unzip(filePath, zipOutputFolder);
const extractedBackupFiles = await RNFetchBlob.fs.ls(zipOutputFolder);
const backupFiles: string[] = [];
const attachmentFiles: string[] = [];
let attachmentsKey: any = null;
for (const path of extractedBackupFiles) {
if (path === ".nnbackup") {
continue;
}
if (path.includes("attachments/.attachments_key"))
attachmentsKey = JSON.parse(
await RNFetchBlob.fs.readFile(path, "utf8")
);
else if (path.includes("attachments/")) attachmentFiles.push(path);
else backupFiles.push(path);
}
let count = 0;
await db.transaction(async () => {
let passwordOrKey: PasswordOrKey;
for (const path of backupFiles) {
if (path === ".nnbackup") continue;
options.updateProgress(
`Restoring data (${count}/${backupFiles.length})`
);
console.log(path);
const filePath = `${zipOutputFolder}/${path}`;
const data = await RNFetchBlob.fs.readFile(filePath, "utf8");
const backup = JSON.parse(data);
console.log(backup.encrypted, "encrypted...");
const isEncryptedBackup = backup.encrypted;
passwordOrKey = !isEncryptedBackup
? ({} as PasswordOrKey)
: await withPassword();
if (
isEncryptedBackup &&
!passwordOrKey?.encryptionKey &&
!passwordOrKey?.password
) {
options.updateProgress(undefined);
throw new Error("Failed to decrypt backup");
}
console.log(typeof backup.data);
await db.backup.import(backup, {
...passwordOrKey,
attachmentsKey: attachmentsKey
});
}
});
await db.initCollections();
count = 0;
for (const path of attachmentFiles) {
options.updateProgress(
`Restoring attachments (${count++}/${attachmentFiles.length})`
);
const hash = path.split("/").pop();
const attachment = await db.attachments.attachment(hash as string);
if (!attachment) continue;
if (attachment.dateUploaded) {
const key = await db.attachments.decryptKey(attachment.key);
if (!key) continue;
const calculatedHash = await Sodium.hashFile({
uri: path,
type: "cache"
});
if (calculatedHash !== attachment.hash) continue;
await db.attachments.reset(attachment.id);
}
await deleteCacheFileByName(hash);
await RNFetchBlob.fs.cp(path, `${cacheDir}/hash`);
}
options.updateProgress(`Cleaning up...`);
// Remove files from cache
RNFetchBlob.fs.unlink(zipOutputFolder).catch(console.log);
if (Platform.OS === "android" || options.deleteFile) {
RNFetchBlob.fs.unlink(filePath).catch(console.log);
}
} else {
options.updateProgress(`Reading backup file...`);
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;
options.updateProgress(
isEncryptedBackup
? `Backup is encrypted, decrypting...`
: "Preparing to restore backup file..."
);
const { encryptionKey, password } = isEncryptedBackup
? ({} as PasswordOrKey)
: await withPassword();
if (isEncryptedBackup && !encryptionKey && !password) {
options.updateProgress(undefined);
throw new Error("Failed to decrypt backup");
}
await db.transaction(async () => {
options.updateProgress("Restoring backup...");
await db.backup.import(backup, {
encryptionKey,
password
});
});
options.updateProgress(undefined);
ToastManager.show({
heading: "Backup restored successfully"
});
}
await db.initCollections();
refreshAllStores();
Navigation.queueRoutesForUpdate();
options.updateProgress(undefined);
} catch (e) {
options.updateProgress(undefined);
DatabaseLogger.error(e as Error);
ToastManager.error(e as Error, `Failed to restore backup`);
}
};
const BACKUP_FILES_CACHE: (ReactNativeBlobUtilStat | ScopedStorage.FileType)[] =
[];
export const RestoreBackup = () => {
const { colors } = useThemeColors();
const [files, setFiles] =
useState<(ReactNativeBlobUtilStat | ScopedStorage.FileType)[]>(
BACKUP_FILES_CACHE
);
const [loading, setLoading] = useState(true);
const [backupDirectoryAndroid, setBackupDirectoryAndroid] =
useState<ScopedStorage.FileType>();
const [progress, setProgress] = useState<string>();
useEffect(() => {
setTimeout(() => {
checkBackups();
}, 1000);
}, []);
const checkBackups = async () => {
try {
let files: (ReactNativeBlobUtilStat | ScopedStorage.FileType)[] = [];
if (Platform.OS === "android") {
const backupDirectory = SettingsService.get().backupDirectoryAndroid;
if (backupDirectory) {
setBackupDirectoryAndroid(backupDirectory);
files = await ScopedStorage.listFiles(backupDirectory.uri);
} else {
setLoading(false);
return;
}
} else {
const path = await storage.checkAndCreateDir("/backups/");
files = await RNFetchBlob.fs.lstat(path);
}
files = files
.filter((file) => {
const name =
Platform.OS === "android"
? (file as ScopedStorage.FileType).name
: (file as ReactNativeBlobUtilStat).filename;
return name.endsWith(".nnbackup") || name.endsWith(".nnbackupz");
})
.sort(function (a, b) {
const timeA = a.lastModified;
const timeB = b.lastModified;
return timeB - timeA;
});
setFiles(files);
BACKUP_FILES_CACHE.splice(0, BACKUP_FILES_CACHE.length, ...files);
} catch (e) {
} finally {
setLoading(false);
}
};
const renderItem = React.useCallback(
({
item,
index
}: {
item: ReactNativeBlobUtilStat | ScopedStorage.FileType;
index: number;
}) => <BackupItem item={item} index={index} updateProgress={setProgress} />,
[]
);
return (
<>
<View>
{progress ? (
<BaseDialog visible>
<DialogContainer
style={{
paddingHorizontal: 12,
paddingBottom: 10
}}
>
<Dialog context="local" />
<View
style={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 50,
gap: 10,
paddingBottom: 20
}}
>
<View
style={{
flexDirection: "row",
width: 100,
paddingTop: 20
}}
>
<ProgressBarComponent
height={5}
width={100}
animated={true}
useNativeDriver
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
<Heading color={colors.primary.paragraph} size={SIZE.lg}>
Creating backup
</Heading>
<Paragraph
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
>
{progress ? progress : "Please wait while we create backup"}
</Paragraph>
<Button
title="Cancel"
type="secondaryAccented"
onPress={() => {
setProgress(undefined);
}}
width="100%"
/>
</View>
</DialogContainer>
</BaseDialog>
) : null}
<SectionItem
item={{
id: "restore-from-files",
name: "Restore from files",
icon: "folder",
modifer: async () => {
useUserStore.setState({
disableAppLockRequests: true
});
const file = await DocumentPicker.pickSingle({
copyTo: "cachesDirectory"
});
setTimeout(() => {
useUserStore.setState({
disableAppLockRequests: false
});
}, 1000);
restoreBackup({
uri: file.fileCopyUri as string,
deleteFile: true,
updateProgress: setProgress
});
},
description: "Restore a backup from files"
}}
/>
<SectionItem
item={{
id: "select-backup-folder",
name: "Select folder with backup files",
icon: "folder",
modifer: async () => {
const folder = await ScopedStorage.openDocumentTree(true);
let subfolder;
if (folder.name !== "Notesnook backups") {
subfolder = await ScopedStorage.createDirectory(
folder.uri,
"Notesnook backups"
);
} else {
subfolder = folder;
}
SettingsService.set({
backupDirectoryAndroid: subfolder
});
setBackupDirectoryAndroid(subfolder);
setLoading(true);
},
description:
"Select folder where backup files are stored to view and restore them from the app"
}}
/>
<FlatList
ListHeaderComponent={
<View
style={{
backgroundColor: colors.primary.background,
marginBottom: 10
}}
>
<Heading color={colors.primary.accent} size={SIZE.xs}>
RECENT BACKUPS
</Heading>
</View>
}
stickyHeaderIndices={[0]}
ListEmptyComponent={
loading ? (
<View
style={{
justifyContent: "center",
alignItems: "center",
height: 300,
paddingHorizontal: 50
}}
>
<ActivityIndicator
color={colors.primary.accent}
size={SIZE.lg}
/>
</View>
) : (
<View
style={{
justifyContent: "center",
alignItems: "center",
gap: 12,
height: 300,
paddingHorizontal: 50
}}
>
<Paragraph
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
>
No backups were found.{" "}
{!backupDirectoryAndroid
? `Please select a folder with backup files to view them here.`
: null}
</Paragraph>
</View>
)
}
windowSize={2}
keyExtractor={(item) =>
(item as ScopedStorage.FileType).name ||
(item as ReactNativeBlobUtilStat).filename
}
style={{
paddingHorizontal: 12
}}
ListFooterComponent={
<View
style={{
height: 200
}}
/>
}
data={files}
renderItem={renderItem}
/>
</View>
</>
);
};
const BackupItem = ({
item,
index,
updateProgress
}: {
item: ReactNativeBlobUtilStat | ScopedStorage.FileType;
index: number;
updateProgress: (progress?: string) => void;
}) => {
const { colors } = useThemeColors();
const isLegacyBackup =
item.path?.endsWith(".nnbackup") ||
(item as ScopedStorage.FileType).uri?.endsWith(".nnbackup");
return (
<View
style={{
justifyContent: "space-between",
alignItems: "center",
width: "100%",
borderRadius: 0,
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: colors.primary.border,
paddingVertical: 12
}}
>
<View>
<Paragraph size={SIZE.sm}>
{(
(item as ReactNativeBlobUtilStat).filename ||
(item as ScopedStorage.FileType).name
)
.replace(".nnbackupz", "")
.replace(".nnbackup", "")}
</Paragraph>
<Paragraph
size={SIZE.xs}
color={colors.secondary.paragraph}
style={{ width: "100%", maxWidth: "100%" }}
>
Created on {getFormattedDate(item?.lastModified, "date-time")}
{isLegacyBackup ? "(Legacy backup)" : ""}
</Paragraph>
</View>
<Button
title="Restore"
type="secondaryAccented"
style={{
paddingHorizontal: 12,
height: 35
}}
onPress={() =>
restoreBackup({
uri:
Platform.OS === "android"
? (item as ScopedStorage.FileType).uri
: item.path,
updateProgress: updateProgress
})
}
/>
</View>
);
};

View File

@@ -24,6 +24,7 @@ import { Appearance, Linking, Platform } from "react-native";
import { getVersion } from "react-native-device-info";
import * as RNIap from "react-native-iap";
import { enabled } from "react-native-privacy-snapshot";
import ScreenGuardModule from "react-native-screenguard";
import { db } from "../../common/database";
import { MMKV } from "../../common/database/mmkv";
import { AttachmentDialog } from "../../components/attachments";
@@ -60,8 +61,7 @@ import {
eCloseSheet,
eCloseSimpleDialog,
eOpenLoginDialog,
eOpenRecoveryKeyDialog,
eOpenRestoreDialog
eOpenRecoveryKeyDialog
} from "../../utils/events";
import { NotesnookModule } from "../../utils/notesnook-module";
import { sleep } from "../../utils/time";
@@ -70,7 +70,6 @@ import { useDragState } from "./editor/state";
import { verifyUser, verifyUserWithApplock } from "./functions";
import { SettingSection } from "./types";
import { getTimeLeft } from "./user-section";
import ScreenGuardModule from "react-native-screenguard";
type User = any;
@@ -434,6 +433,18 @@ export const settingsGroups: SettingSection[] = [
type: "screen",
icon: "autorenew",
sections: [
{
id: "offline-mode",
name: "Full offline mode",
description: "Download everything including attachments on sync",
type: "switch",
property: "offlineMode",
onChange: (value) => {
if (value) {
Sync.run(undefined, false, "fetch");
}
}
},
{
id: "auto-sync",
name: "Disable auto sync",
@@ -1041,15 +1052,35 @@ export const settingsGroups: SettingSection[] = [
{
id: "backup-now",
name: "Backup now",
description: "Create a backup of your data",
description:
"Take a partial backup of your data that does not include attachments",
modifer: async () => {
const user = useUserStore.getState().user;
if (!user || SettingsService.getProperty("encryptedBackup")) {
await BackupService.run(true);
await BackupService.run(true, undefined, "partial");
return;
}
verifyUser(null, () => BackupService.run(true));
verifyUser(null, () =>
BackupService.run(true, undefined, "partial")
);
}
},
{
id: "backup-now",
name: "Backup now with attachments",
hidden: () => !useUserStore.getState().user,
description: "Take a full backup of your data with all attachments",
modifer: async () => {
const user = useUserStore.getState().user;
if (!user || SettingsService.getProperty("encryptedBackup")) {
await BackupService.run(true, undefined, "full");
return;
}
verifyUser(null, () =>
BackupService.run(true, undefined, "full")
);
}
},
{
@@ -1057,9 +1088,19 @@ export const settingsGroups: SettingSection[] = [
type: "component",
name: "Automatic backups",
description:
"Backup your data once every week or daily automatically.",
"Set the interval to create a partial backup (without attachments) automatically.",
component: "autobackups"
},
{
id: "auto-backups-with-attachments",
type: "component",
hidden: () => !useUserStore.getState().user,
name: "Automatic backups with attachments",
description: `Set the interval to create a backup (with attachments) automatically.
NOTE: Creating a backup with attachments can take a while, and also fail completely. The app will try to resume/restart the backup in case of interruptions.`,
component: "autobackupsattachments"
},
{
id: "select-backup-dir",
name: "Select backup directory",
@@ -1151,9 +1192,8 @@ export const settingsGroups: SettingSection[] = [
id: "restore-backup",
name: "Restore backup",
description: "Restore backup from phone storage.",
modifer: () => {
eSendEvent(eOpenRestoreDialog);
}
type: "screen",
component: "backuprestore"
},
{
id: "export-notes",

View File

@@ -26,6 +26,7 @@ import { DatabaseLogger, db, setupDatabase } from "../common/database";
import { AppState, AppRegistry } from "react-native";
import Notifications from "./notifications";
import SettingsService from "./settings";
import { deleteDCacheFiles } from "../common/filesystem/io";
async function doInBackground(callback: () => Promise<void>) {
if (Platform.OS === "ios") {
@@ -121,6 +122,7 @@ async function onBackgroundSyncStarted() {
});
}
await Notifications.setupReminders();
deleteDCacheFiles();
DatabaseLogger.info("BACKGROUND SYNC COMPLETE");
} catch (e) {
DatabaseLogger.error(e as Error);

View File

@@ -17,6 +17,8 @@ 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/dist/utils/date";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import FileViewer from "react-native-file-viewer";
@@ -25,8 +27,14 @@ import Share from "react-native-share";
import { zip } from "react-native-zip-archive";
import { DatabaseLogger, db } from "../common/database";
import storage from "../common/database/storage";
import filesystem 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";
@@ -37,14 +45,14 @@ const MS_WEEK = MS_DAY * 7;
const MONTH = MS_DAY * 30;
async function getDirectoryAndroid() {
let folder = await ScopedStorage.openDocumentTree(true);
const folder = await ScopedStorage.openDocumentTree(true);
if (!folder) return null;
let subfolder;
if (!folder.name.includes("Notesnook backups")) {
let folderFiles = await ScopedStorage.listFiles(folder.uri);
for (let f of folderFiles) {
if (f.type === "directory" && f.name === "Notesnook backups") {
subfolder = f;
const files = await ScopedStorage.listFiles(folder.uri);
for (const file of files) {
if (file.type === "directory" && file.name === "Notesnook backups") {
subfolder = file;
}
}
if (!subfolder) {
@@ -67,10 +75,10 @@ async function checkBackupDirExists(reset = false, context = "global") {
let dir = SettingsService.get().backupDirectoryAndroid;
if (reset) dir = null;
if (dir) {
let allDirs = await ScopedStorage.getPersistedUriPermissions();
const allDirs = await ScopedStorage.getPersistedUriPermissions();
let exists = allDirs.findIndex((d) => {
return d === dir.uri || dir.uri.includes(d);
});
return d === dir?.uri || dir?.uri.includes(d);
}) as number | boolean;
exists = exists !== -1;
dir = exists ? dir : null;
}
@@ -100,7 +108,7 @@ async function checkBackupDirExists(reset = false, context = "global") {
return dir;
}
async function presentBackupCompleteSheet(backupFilePath) {
async function presentBackupCompleteSheet(backupFilePath: string) {
presentSheet({
title: "Backup complete",
icon: "cloud-upload",
@@ -123,7 +131,7 @@ async function presentBackupCompleteSheet(backupFilePath) {
showOpenWithDialog: true,
showAppsSuggestions: true,
shareFile: true
}).catch(console.log);
} as any).catch(console.log);
}
},
actionText: "Share"
@@ -153,28 +161,45 @@ async function updateNextBackupTime() {
* @param {string=} context
* @returns {Promise<{path?: string, error?: Error, report?: boolean}}>
*/
async function run(progress = false, context) {
let androidBackupDirectory = await checkBackupDirExists(false, context);
async function run(
progress = false,
context?: string,
backupType?: "full" | "partial"
) {
console.log("Creating backup:", backupType, progress, context);
const androidBackupDirectory = (await checkBackupDirExists(
false,
context
)) as ScopedStorage.FileType;
if (!androidBackupDirectory)
return {
error: new Error("Backup directory not selected"),
report: false
};
if (progress) {
presentSheet({
title: "Backing up your data",
paragraph:
"All your backups are stored in 'Phone Storage/Notesnook/backups/' folder",
progress: true
});
}
let path;
let backupFileName = "notesnook_backup_" + Date.now();
if (Platform.OS === "ios") {
path = await storage.checkAndCreateDir("/backups/");
path = await storage.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: `Creating ${backupType === "full" ? "a full " : ""}backup`,
paragraph: `Please wait while we create a backup of your data. This may take a few minutes.`,
progress: undefined
});
}
const zipSourceFolder = `${cacheDir}/${backupFileName}`;
@@ -190,26 +215,50 @@ async function run(progress = false, context) {
await RNFetchBlob.fs.mkdir(zipSourceFolder);
const attachmentsDir = zipSourceFolder + "/attachments";
if (backupType === "full") {
await RNFetchBlob.fs.mkdir(attachmentsDir);
}
try {
const user = await db.user.getUser();
for await (const file of db.backup.export(
"mobile",
SettingsService.get().encryptedBackup && user
)) {
console.log("Writing backup chunk of size...", file?.data?.length);
await RNFetchBlob.fs.writeFile(
`${zipSourceFolder}/${file.path}`,
file.data,
"utf8"
);
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 filesystem.exists(file.hash)) {
await RNFetchBlob.fs.cp(
`${cacheDir}/${file.hash}`,
`${attachmentsDir}/${file.hash}`
);
}
}
}
updateProgress({
progress: "Creating backup zip file..."
});
await zip(zipSourceFolder, zipOutputFile);
if (Platform.OS === "android") {
// Move the zip to user selected directory.
const file = await ScopedStorage.createFile(
androidBackupDirectory.uri,
androidBackupDirectory?.uri,
`${backupFileName}.nnbackupz`,
"application/nnbackupz"
);
@@ -223,7 +272,9 @@ async function run(progress = false, context) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
updateNextBackupTime();
let showBackupCompleteSheet =
endProgress();
const canShowCompletionStatus =
progress && SettingsService.get().showBackupCompleteSheet;
if (context) {
@@ -234,15 +285,12 @@ async function run(progress = false, context) {
await sleep(300);
if (showBackupCompleteSheet) {
if (canShowCompletionStatus) {
presentBackupCompleteSheet(path);
} else {
progress && eSendEvent(eCloseSheet);
}
ToastManager.show({
heading: "Backup successful",
message: "Your backup is stored in Notesnook folder on your phone.",
type: "success",
context: "global"
});
@@ -251,16 +299,19 @@ async function run(progress = false, context) {
path: path
};
} catch (e) {
ToastManager.error(e, "Backup failed", context || "global");
ToastManager.error(e as Error, "Backup failed", context || "global");
if (e?.message?.includes("android.net.Uri") && androidBackupDirectory) {
if (
(e as Error)?.message?.includes("android.net.Uri") &&
androidBackupDirectory
) {
SettingsService.setProperty("backupDirectoryAndroid", null);
return run(progress, context);
return run(progress, context, backupType);
}
DatabaseLogger.error(e);
await sleep(300);
progress && eSendEvent(eCloseSheet);
endProgress();
return {
error: e,
report: true
@@ -268,18 +319,25 @@ async function run(progress = false, context) {
}
}
async function getLastBackupDate() {
return SettingsService.get().lastBackupDate;
}
async function checkBackupRequired(
type: "daily" | "off" | "useroff" | "weekly" | "monthly" | "never",
lastBackupDateType: "lastBackupDate" | "lastFullBackupDate" = "lastBackupDate"
) {
console.log(lastBackupDateType, type);
if (type === "off" || type === "useroff" || type === "never" || !type) return;
const now = Date.now();
const lastBackupDate = SettingsService.getProperty(lastBackupDateType) as
| number
| "never";
async function checkBackupRequired(type) {
if (type === "off" || type === "useroff") return;
let now = Date.now();
let lastBackupDate = await getLastBackupDate();
if (!lastBackupDate || lastBackupDate === "never") {
if (
lastBackupDate === undefined ||
lastBackupDate === "never" ||
lastBackupDate === 0
) {
return true;
}
lastBackupDate = parseInt(lastBackupDate);
if (type === "daily" && lastBackupDate + MS_DAY < now) {
DatabaseLogger.info("Daily backup started");
return true;
@@ -294,8 +352,8 @@ async function checkBackupRequired(type) {
}
const checkAndRun = async () => {
let settings = SettingsService.get();
if (await checkBackupRequired(settings.reminder)) {
const settings = SettingsService.get();
if (await checkBackupRequired(settings?.reminder)) {
try {
await run();
} catch (e) {

View File

@@ -86,7 +86,8 @@ const run = async (
try {
await db.sync({
type: type,
force: forced
force: forced,
offlineMode: SettingsService.get().offlineMode
});
} catch (e) {
error = e;

View File

@@ -32,7 +32,8 @@ export type Settings = {
fontScale?: number;
forcePortraitOnTablet?: boolean;
useSystemTheme?: boolean;
reminder?: string;
reminder: "daily" | "off" | "useroff" | "weekly" | "monthly";
fullBackupReminder: "never" | "weekly" | "monthly";
encryptedBackup?: boolean;
homepage?: string;
sort?: string;
@@ -52,8 +53,8 @@ export type Settings = {
rateApp?: boolean | number;
migrated?: boolean;
introCompleted?: boolean;
nextBackupRequestTime?: number | undefined;
lastBackupDate?: number | undefined;
nextBackupRequestTime?: number;
lastBackupDate?: number;
userEmailConfirmed?: boolean;
recoveryKeySaved?: boolean;
backupDirectoryAndroid?: FileType | null;
@@ -82,6 +83,9 @@ export type Settings = {
backgroundSync?: boolean;
applockKeyboardType: "numeric" | "default";
settingsVersion?: number;
backupType: "full" | "partial";
offlineMode?: boolean;
lastFullBackupDate?: number;
};
type DimensionsType = {
@@ -150,7 +154,7 @@ export const defaultSettings: SettingStore["settings"] = {
migrated: false,
introCompleted: Config.isTesting ? true : false,
nextBackupRequestTime: undefined,
lastBackupDate: undefined,
lastBackupDate: 0,
userEmailConfirmed: false,
recoveryKeySaved: false,
showBackupCompleteSheet: true,
@@ -171,7 +175,10 @@ export const defaultSettings: SettingStore["settings"] = {
biometricsAuthEnabled: false,
appLockHasPasswordSecurity: false,
backgroundSync: true,
settingsVersion: 0
settingsVersion: 0,
backupType: "partial",
fullBackupReminder: "never",
lastFullBackupDate: 0
};
export const useSettingStore = create<SettingStore>((set, get) => ({

View File

@@ -254,7 +254,7 @@ class Database {
this.sql().withTables(),
new NNMigrationProvider()
);
await this.onInit(this.sql() as Kysely<RawDatabaseSchema>);
await this.onInit(this.sql() as unknown as Kysely<RawDatabaseSchema>);
await this.initCollections();
return true;
}

View File

@@ -57,6 +57,7 @@ import { DefaultColors } from "../../collections/colors";
export type SyncOptions = {
type: "full" | "fetch" | "send";
force?: boolean;
offlineMode?: boolean;
};
export default class SyncManager {
@@ -246,7 +247,7 @@ class Sync {
await this.db.setLastSynced(Date.now());
this.db.eventManager.publish(EVENTS.syncCompleted);
if (await this.db.kv().read("fullOfflineMode")) {
if (options.offlineMode) {
const attachments = await this.db.attachments.linked
.fields(["attachments.id", "attachments.hash", "attachments.chunkSize"])
.items();

View File

@@ -33,6 +33,7 @@ export class Crypto {
export function isCipher(item: any): item is Cipher<"base64"> {
return (
item !== null &&
typeof item === "object" &&
"cipher" in item &&
"iv" in item &&