From 0dd9f2bd94967eccc22f210206eaa287a37257e7 Mon Sep 17 00:00:00 2001 From: ilona Date: Sat, 18 Oct 2025 18:03:59 +0200 Subject: [PATCH] feat: Reimplement Local Backup Based on Self-Hosted Backup (#1603) Closes #1276 --- .../NativeZipArchive/NativeZipArchive.kt | 14 ++ ios/NativeZipArchive/RCTNativeZipArchive.mm | 3 + package-lock.json | 11 +- package.json | 4 +- specs/NativeZipArchive.ts | 1 + .../settings/SettingsBackupScreen/index.tsx | 18 +-- src/services/backup/legacy/index.ts | 141 ------------------ src/services/backup/local/index.ts | 70 +++++++++ 8 files changed, 107 insertions(+), 155 deletions(-) delete mode 100644 src/services/backup/legacy/index.ts create mode 100644 src/services/backup/local/index.ts diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeZipArchive/NativeZipArchive.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeZipArchive/NativeZipArchive.kt index fbd1e091..81b1276f 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeZipArchive/NativeZipArchive.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeZipArchive/NativeZipArchive.kt @@ -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( diff --git a/ios/NativeZipArchive/RCTNativeZipArchive.mm b/ios/NativeZipArchive/RCTNativeZipArchive.mm index 811e6634..fb513dc7 100644 --- a/ios/NativeZipArchive/RCTNativeZipArchive.mm +++ b/ios/NativeZipArchive/RCTNativeZipArchive.mm @@ -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 diff --git a/package-lock.json b/package-lock.json index 64d2453a..4a6e9178 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 46eaac42..28cde295 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 93eced1d..a6d0f54a 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -1,6 +1,7 @@ import { TurboModule, TurboModuleRegistry } from 'react-native'; export interface Spec extends TurboModule { + zip: (sourceDirPath: string, destFilePath: string) => Promise; unzip: (sourceFilePath: string, distDirPath: string) => Promise; remoteUnzip: ( distDirPath: string, diff --git a/src/screens/settings/SettingsBackupScreen/index.tsx b/src/screens/settings/SettingsBackupScreen/index.tsx index ee882d17..02a2693e 100644 --- a/src/screens/settings/SettingsBackupScreen/index.tsx +++ b/src/screens/settings/SettingsBackupScreen/index.tsx @@ -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')} deprecatedRestoreBackup()} + onPress={restoreLocalBackup} theme={theme} /> { - 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( - restoreBackupBackgroundAction, - notificationOptions, - ); - } - } catch (error: any) { - showToast(error.message); - } -}; diff --git a/src/services/backup/local/index.ts b/src/services/backup/local/index.ts new file mode 100644 index 00000000..a8b63443 --- /dev/null +++ b/src/services/backup/local/index.ts @@ -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); + } +};