feat: Reimplement Local Backup Based on Self-Hosted Backup (#1603)
Closes #1276
This commit is contained in:
+14
@@ -35,6 +35,20 @@ class NativeZipArchive(context: ReactApplicationContext) : NativeZipArchiveSpec(
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
override fun zip(sourceDirPath: String, zipFilePath: String, promise: Promise) {
|
||||
Thread {
|
||||
try {
|
||||
FileOutputStream(zipFilePath).use { fos ->
|
||||
ZipOutputStream(fos).use { zos -> zipProcess(sourceDirPath, zos) }
|
||||
}
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
promise.reject(e)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
override fun remoteUnzip(
|
||||
|
||||
@@ -28,5 +28,8 @@
|
||||
- (void)unzip:(nonnull NSString *)sourceFilePath distDirPath:(nonnull NSString *)distDirPath resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
|
||||
// TODO: implement unzip
|
||||
}
|
||||
- (void)zip:(nonnull NSString *)sourceDirPath zipFilePath:(nonnull NSString *)zipFilePath resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
|
||||
// TODO: implement zip
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
Generated
+10
-1
@@ -12,6 +12,7 @@
|
||||
"@gorhom/bottom-sheet": "^5.1.4",
|
||||
"@react-native-community/slider": "4.5.6",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
"@react-native-documents/picker": "^10.1.7",
|
||||
"@react-native-google-signin/google-signin": "^14.0.0",
|
||||
"@react-native-vector-icons/common": "^12.0.0",
|
||||
"@react-native-vector-icons/material-design-icons": "^12.0.0",
|
||||
@@ -49,7 +50,6 @@
|
||||
"react-native-drawer-layout": "^4.1.8",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-error-boundary": "^2.0.0",
|
||||
"react-native-file-access": "^3.1.1",
|
||||
"react-native-gesture-handler": "~2.25.0",
|
||||
"react-native-lottie-splash-screen": "^1.1.2",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
@@ -4198,6 +4198,15 @@
|
||||
"react-native": ">= 0.60.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-documents/picker": {
|
||||
"version": "10.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-documents/picker/-/picker-10.1.7.tgz",
|
||||
"integrity": "sha512-Tb8SPU+pHxrSJmDHBozSUStIPeyFHTHLrU3MW0N3sUAioLd5z+nmUdypfg5fs+Yzp7KTxVW06APe2HLB1ysLww==",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-google-signin/google-signin": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-14.0.0.tgz",
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@
|
||||
"android": {
|
||||
"javaPackageName": "com.lnreader.spec"
|
||||
},
|
||||
"ios": {
|
||||
"ios": {
|
||||
"modulesProvider": {
|
||||
"NativeEpubUtil": "RCTNativeEpubUtil",
|
||||
"NativeFile": "RCTNativeFile",
|
||||
@@ -39,6 +39,7 @@
|
||||
"@gorhom/bottom-sheet": "^5.1.4",
|
||||
"@react-native-community/slider": "4.5.6",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
"@react-native-documents/picker": "^10.1.7",
|
||||
"@react-native-google-signin/google-signin": "^14.0.0",
|
||||
"@react-native-vector-icons/common": "^12.0.0",
|
||||
"@react-native-vector-icons/material-design-icons": "^12.0.0",
|
||||
@@ -76,7 +77,6 @@
|
||||
"react-native-drawer-layout": "^4.1.8",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-error-boundary": "^2.0.0",
|
||||
"react-native-file-access": "^3.1.1",
|
||||
"react-native-gesture-handler": "~2.25.0",
|
||||
"react-native-lottie-splash-screen": "^1.1.2",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TurboModule, TurboModuleRegistry } from 'react-native';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
zip: (sourceDirPath: string, destFilePath: string) => Promise<void>;
|
||||
unzip: (sourceFilePath: string, distDirPath: string) => Promise<void>;
|
||||
remoteUnzip: (
|
||||
distDirPath: string,
|
||||
|
||||
@@ -6,9 +6,9 @@ import { BackupSettingsScreenProps } from '@navigators/types';
|
||||
import GoogleDriveModal from './Components/GoogleDriveModal';
|
||||
import SelfHostModal from './Components/SelfHostModal';
|
||||
import {
|
||||
createBackup as deprecatedCreateBackup,
|
||||
restoreBackup as deprecatedRestoreBackup,
|
||||
} from '@services/backup/legacy';
|
||||
createBackup as createLocalBackup,
|
||||
restoreBackup as restoreLocalBackup,
|
||||
} from '@services/backup/local';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import { getString } from '@strings/translations';
|
||||
import { StyleSheet } from 'react-native';
|
||||
@@ -56,19 +56,15 @@ const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
|
||||
{getString('backupScreen.legacyBackup')}
|
||||
</List.SubHeader>
|
||||
<List.Item
|
||||
title={`${getString('backupScreen.createBackup')} (${getString(
|
||||
'common.deprecated',
|
||||
)})`}
|
||||
title={getString('backupScreen.createBackup')}
|
||||
description={getString('backupScreen.createBackupDesc')}
|
||||
onPress={deprecatedCreateBackup}
|
||||
onPress={createLocalBackup}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title={`${getString('backupScreen.restoreBackup')} (${getString(
|
||||
'common.deprecated',
|
||||
)})`}
|
||||
title={getString('backupScreen.restoreBackup')}
|
||||
description={getString('backupScreen.restoreBackupDesc')}
|
||||
onPress={() => deprecatedRestoreBackup()}
|
||||
onPress={restoreLocalBackup}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.InfoItem
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import BackgroundService from 'react-native-background-actions';
|
||||
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { restoreLibrary } from '@database/queries/NovelQueries';
|
||||
import { getLibraryNovelsFromDb } from '@database/queries/LibraryQueries';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import dayjs from 'dayjs';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { sleep } from '@utils/sleep';
|
||||
import { getString } from '@strings/translations';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export const createBackup = async () => {
|
||||
try {
|
||||
const novels = getLibraryNovelsFromDb();
|
||||
|
||||
const datetime = dayjs().format('YYYY-MM-DD_HH_mm');
|
||||
const fileName = 'lnreader_backup_' + datetime + '.json';
|
||||
const fileContent = JSON.stringify(novels);
|
||||
const permissions =
|
||||
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
||||
|
||||
if (!permissions.granted) {
|
||||
showToast(getString('backupScreen.failed'));
|
||||
return;
|
||||
}
|
||||
const directoryUri = permissions.directoryUri;
|
||||
await FileSystem.StorageAccessFramework.createFileAsync(
|
||||
directoryUri,
|
||||
fileName,
|
||||
'application/json',
|
||||
)
|
||||
.then(async uri => {
|
||||
await FileSystem.writeAsStringAsync(uri, fileContent, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
showToast(getString('backupScreen.legacy.backupCreated', { fileName }));
|
||||
})
|
||||
.catch(error => {
|
||||
showToast(
|
||||
getString('backupScreen.failed', {
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (error: any) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
BackgroundService.stop();
|
||||
}
|
||||
};
|
||||
|
||||
interface TaskData {
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export const restoreBackup = async () => {
|
||||
try {
|
||||
const backup = await DocumentPicker.getDocumentAsync({
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
let novelsString = '';
|
||||
|
||||
if (backup.assets && backup.assets[0]) {
|
||||
novelsString = await FileSystem.StorageAccessFramework.readAsStringAsync(
|
||||
backup.assets[0].uri,
|
||||
);
|
||||
}
|
||||
const novels: NovelInfo[] = await JSON.parse(novelsString);
|
||||
if (novels.length === 0) {
|
||||
showToast(getString('backupScreen.legacy.noAvailableBackup'));
|
||||
return;
|
||||
}
|
||||
const notificationOptions = {
|
||||
taskName: 'Backup Restore',
|
||||
taskTitle: getString('backupScreen.restorinBackup'),
|
||||
taskDesc: '(0/' + novels.length + ')',
|
||||
taskIcon: { name: 'notification_icon', type: 'drawable' },
|
||||
color: '#00adb5',
|
||||
parameters: { delay: 1000 },
|
||||
linkingURI: 'lnreader://updates',
|
||||
progressBar: { max: novels.length, value: 0 },
|
||||
};
|
||||
|
||||
const restoreBackupBackgroundAction = async (taskData?: TaskData) => {
|
||||
let errorString = '';
|
||||
let restoredNovelsCount = 0;
|
||||
for (let i = 0; BackgroundService.isRunning() && i < novels.length; i++) {
|
||||
try {
|
||||
if (BackgroundService.isRunning()) {
|
||||
const plugin = getPlugin(novels[i].pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error(`No plugin found with id ${novels[i].pluginId}`);
|
||||
}
|
||||
BackgroundService.updateNotification({
|
||||
taskTitle: novels[i].name,
|
||||
taskDesc: '(' + (i + 1) + '/' + novels.length + ')',
|
||||
progressBar: { max: novels.length, value: i + 1 },
|
||||
});
|
||||
await restoreLibrary(novels[i]);
|
||||
restoredNovelsCount += 1;
|
||||
const nextNovelIndex = i + 1;
|
||||
|
||||
if (
|
||||
nextNovelIndex in novels &&
|
||||
novels[nextNovelIndex].pluginId === novels[i].pluginId
|
||||
) {
|
||||
await sleep(taskData?.delay || 0);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errorString += e + '\n';
|
||||
}
|
||||
}
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: getString('backupScreen.legacy.libraryRestored'),
|
||||
body:
|
||||
getString('backupScreen.legacy.novelsRestored', {
|
||||
num: restoredNovelsCount,
|
||||
}) +
|
||||
'\n' +
|
||||
errorString,
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
BackgroundService.stop();
|
||||
};
|
||||
|
||||
if (novels.length > 0) {
|
||||
await BackgroundService.start<TaskData>(
|
||||
restoreBackupBackgroundAction,
|
||||
notificationOptions,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error.message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
|
||||
import { showToast } from '@utils/showToast';
|
||||
import dayjs from 'dayjs';
|
||||
import { saveDocuments, pick, types, keepLocalCopy } from '@react-native-documents/picker';
|
||||
import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
|
||||
import NativeZipArchive from '@specs/NativeZipArchive';
|
||||
import { ROOT_STORAGE } from '@utils/Storages';
|
||||
import { ZipBackupName } from '../types';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
|
||||
export const createBackup = async () => {
|
||||
try {
|
||||
|
||||
await prepareBackupData(CACHE_DIR_PATH);
|
||||
|
||||
await NativeZipArchive.zip(ROOT_STORAGE, CACHE_DIR_PATH + '/' + ZipBackupName.DOWNLOAD)
|
||||
await NativeZipArchive.zip(CACHE_DIR_PATH, CACHE_DIR_PATH + '.zip')
|
||||
|
||||
|
||||
|
||||
const datetime = dayjs().format('YYYY-MM-DD_HH_mm');
|
||||
const fileName = 'lnreader_backup_' + datetime + '.zip';
|
||||
|
||||
await saveDocuments({
|
||||
sourceUris: ['file://'+CACHE_DIR_PATH+'.zip'],
|
||||
copy: false,
|
||||
mimeType: 'application/zip',
|
||||
fileName
|
||||
});
|
||||
} catch (error: any) {
|
||||
showToast(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreBackup = async () => {
|
||||
try {
|
||||
const [result] = await pick({
|
||||
mode: 'import',
|
||||
type: [types.zip],
|
||||
allowVirtualFiles: true, // TODO: hopefully this just works
|
||||
});
|
||||
|
||||
if(NativeFile.exists(CACHE_DIR_PATH)) {
|
||||
NativeFile.unlink(CACHE_DIR_PATH);
|
||||
}
|
||||
|
||||
const [localRes] = await keepLocalCopy({
|
||||
files: [{
|
||||
uri: result.uri,
|
||||
fileName: 'backup.zip'
|
||||
}],
|
||||
destination: 'cachesDirectory'
|
||||
});
|
||||
if(localRes.status === 'error') {
|
||||
throw new Error(localRes.copyError);
|
||||
}
|
||||
|
||||
const localPath = localRes.localUri.replace(/^file:(\/\/)?\//, '/');
|
||||
|
||||
// TODO: error handling
|
||||
|
||||
await NativeZipArchive.unzip(localPath, CACHE_DIR_PATH);
|
||||
// TODO: unlink here too?
|
||||
await NativeZipArchive.unzip(CACHE_DIR_PATH + '/' + ZipBackupName.DOWNLOAD, ROOT_STORAGE);
|
||||
|
||||
await restoreData(CACHE_DIR_PATH);
|
||||
} catch (error: any) {
|
||||
showToast(error.message);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user