feat: Reimplement Local Backup Based on Self-Hosted Backup (#1603)

Closes #1276
This commit is contained in:
ilona
2025-10-18 18:03:59 +02:00
committed by GitHub
parent 80228ada1e
commit 0dd9f2bd94
8 changed files with 107 additions and 155 deletions
@@ -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
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
-141
View File
@@ -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);
}
};
+70
View File
@@ -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);
}
};