From bfacc4a1ee8605d2b388db2dac462b88dcdd96a6 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 4 Oct 2025 20:29:05 +0530 Subject: [PATCH] local backup restore feature --- ios/Nuvio.xcodeproj/project.pbxproj | 48 +- ios/Podfile.lock | 37 ++ package-lock.json | 20 + package.json | 2 + src/components/BackupRestoreSettings.tsx | 310 +++++++++++ src/navigation/AppNavigator.tsx | 22 +- src/screens/BackupScreen.tsx | 358 ++++++++++++ src/screens/SettingsScreen.tsx | 21 +- src/services/backupService.ts | 666 +++++++++++++++++++++++ 9 files changed, 1479 insertions(+), 5 deletions(-) create mode 100644 src/components/BackupRestoreSettings.tsx create mode 100644 src/screens/BackupScreen.tsx create mode 100644 src/services/backupService.ts diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index fa100a58..0f31caf4 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -302,6 +302,25 @@ "${PODS_CONFIGURATION_BUILD_DIR}/KSPlayer/KSPlayer_KSPlayer.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Brands.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Regular.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Solid.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf", "${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", @@ -328,6 +347,25 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/KSPlayer_KSPlayer.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Brands.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Regular.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Solid.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialCommunityIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Octicons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", @@ -526,7 +564,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -581,7 +622,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f368c4fb..875ca833 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -232,6 +232,8 @@ PODS: - ExpoModulesCore - ExpoDevice (7.0.3): - ExpoModulesCore + - ExpoDocumentPicker (14.0.7): + - ExpoModulesCore - ExpoFileSystem (18.0.12): - ExpoModulesCore - ExpoFont (13.0.4): @@ -302,6 +304,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - ExpoSharing (14.0.7): + - ExpoModulesCore - ExpoSystemUI (4.0.9): - ExpoModulesCore - ExpoWebBrowser (14.0.2): @@ -2453,6 +2457,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNVectorIcons (10.3.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.10.14.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - SDWebImage (5.19.7): - SDWebImage/Core (= 5.19.7) - SDWebImage/Core (5.19.7) @@ -2485,6 +2510,7 @@ DEPENDENCIES: - ExpoBrightness (from `../node_modules/expo-brightness/ios`) - ExpoCrypto (from `../node_modules/expo-crypto/ios`) - ExpoDevice (from `../node_modules/expo-device/ios`) + - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) - ExpoHaptics (from `../node_modules/expo-haptics/ios`) @@ -2497,6 +2523,7 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoRandom (from `../node_modules/expo-random/ios`) - ExpoScreenOrientation (from `../node_modules/expo-screen-orientation/ios`) + - ExpoSharing (from `../node_modules/expo-sharing/ios`) - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) - EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`) @@ -2584,6 +2611,7 @@ DEPENDENCIES: - RNScreens (from `../node_modules/react-native-screens`) - "RNSentry (from `../node_modules/@sentry/react-native`)" - RNSVG (from `../node_modules/react-native-svg`) + - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -2639,6 +2667,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-crypto/ios" ExpoDevice: :path: "../node_modules/expo-device/ios" + ExpoDocumentPicker: + :path: "../node_modules/expo-document-picker/ios" ExpoFileSystem: :path: "../node_modules/expo-file-system/ios" ExpoFont: @@ -2663,6 +2693,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-random/ios" ExpoScreenOrientation: :path: "../node_modules/expo-screen-orientation/ios" + ExpoSharing: + :path: "../node_modules/expo-sharing/ios" ExpoSystemUI: :path: "../node_modules/expo-system-ui/ios" ExpoWebBrowser: @@ -2837,6 +2869,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@sentry/react-native" RNSVG: :path: "../node_modules/react-native-svg" + RNVectorIcons: + :path: "../node_modules/react-native-vector-icons" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -2874,6 +2908,7 @@ SPEC CHECKSUMS: ExpoBrightness: c0011699a3225c869666e266326774a6fb6a9075 ExpoCrypto: e97e864c8d7b9ce4a000bca45dddb93544a1b2b4 ExpoDevice: d36ab4186b6799a28fd449bb9a1c77455f23fd1a + ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926 ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655 ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188 ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d @@ -2886,6 +2921,7 @@ SPEC CHECKSUMS: ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993 ExpoRandom: d1444df65007bdd4070009efd5dab18e20bf0f00 ExpoScreenOrientation: af8b31d3164239a4ef3ea0b32bd63fb65df70d58 + ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9 ExpoSystemUI: b82a45cf0f6a4fa18d07c46deba8725dd27688b4 ExpoWebBrowser: a212e6b480d8857d3e441fba51e0c968333803b3 EXStructuredHeaders: 09c70347b282e3d2507e25fb4c747b1b885f87f6 @@ -2976,6 +3012,7 @@ SPEC CHECKSUMS: RNScreens: 362f4c861dd155f898908d5035d97b07a3f1a9d1 RNSentry: ac7beae04304d95491a512b5abf6926d4501c73c RNSVG: b889dc9c1948eeea0576a16cc405c91c37a12c19 + RNVectorIcons: c95fdae217b0ed388f2b4d7ed7a4edc457c1df47 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/package-lock.json b/package-lock.json index b4685616..e92aa7a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "expo-crypto": "~14.0.2", "expo-dev-client": "~5.0.20", "expo-device": "~7.0.3", + "expo-document-picker": "^14.0.7", "expo-file-system": "~18.0.12", "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", @@ -52,6 +53,7 @@ "expo-notifications": "~0.29.14", "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", + "expo-sharing": "^14.0.7", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-updates": "~0.27.4", @@ -7880,6 +7882,15 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz", + "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-eas-client": { "version": "0.13.3", "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-0.13.3.tgz", @@ -8132,6 +8143,15 @@ "react-native": "*" } }, + "node_modules/expo-sharing": { + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", + "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.0.1.tgz", diff --git a/package.json b/package.json index a9931c03..ed2ab4ee 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "expo-crypto": "~14.0.2", "expo-dev-client": "~5.0.20", "expo-device": "~7.0.3", + "expo-document-picker": "^14.0.7", "expo-file-system": "~18.0.12", "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", @@ -52,6 +53,7 @@ "expo-notifications": "~0.29.14", "expo-random": "^14.0.1", "expo-screen-orientation": "~8.0.4", + "expo-sharing": "^14.0.7", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-updates": "~0.27.4", diff --git a/src/components/BackupRestoreSettings.tsx b/src/components/BackupRestoreSettings.tsx new file mode 100644 index 00000000..f30d7e95 --- /dev/null +++ b/src/components/BackupRestoreSettings.tsx @@ -0,0 +1,310 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + Platform, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Sharing from 'expo-sharing'; +import { backupService, BackupOptions } from '../services/backupService'; +import { useTheme } from '../contexts/ThemeContext'; +import { logger } from '../utils/logger'; +import CustomAlert from './CustomAlert'; + +interface BackupRestoreSettingsProps { + isTablet?: boolean; +} + +const BackupRestoreSettings: React.FC = ({ isTablet = false }) => { + const { currentTheme } = useTheme(); + const [isLoading, setIsLoading] = useState(false); + + // Alert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + + const openAlert = ( + title: string, + message: string, + actions?: Array<{ label: string; onPress: () => void; style?: object }> + ) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertVisible(true); + }; + + + // Create backup + const handleCreateBackup = useCallback(async () => { + try { + // First, get backup preview to show what will be backed up + setIsLoading(true); + const preview = await backupService.getBackupPreview(); + setIsLoading(false); + + // Calculate total without downloads + const totalWithoutDownloads = preview.library + preview.watchProgress + preview.addons + preview.scrapers; + + openAlert( + 'Create Backup', + `Backup Contents:\n\n` + + `Library: ${preview.library} items\n` + + `Watch Progress: ${preview.watchProgress} entries\n` + + `Addons: ${preview.addons} installed\n` + + `Plugins: ${preview.scrapers} configurations\n\n` + + `Total: ${totalWithoutDownloads} items\n\n` + + `This backup includes all your app settings, themes, and integration data.`, + [ + { label: 'Cancel', onPress: () => {} }, + { + label: 'Create Backup', + onPress: async () => { + try { + setIsLoading(true); + + const backupOptions: BackupOptions = { + includeLibrary: true, + includeWatchProgress: true, + includeDownloads: true, + includeAddons: true, + includeSettings: true, + includeTraktData: true, + includeLocalScrapers: true, + }; + + const fileUri = await backupService.createBackup(backupOptions); + + // Share the backup file + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(fileUri, { + mimeType: 'application/json', + dialogTitle: 'Share Nuvio Backup', + }); + } + + openAlert( + 'Backup Created', + 'Your backup has been created and is ready to share.', + [{ label: 'OK', onPress: () => {} }] + ); + } catch (error) { + logger.error('[BackupRestoreSettings] Failed to create backup:', error); + openAlert( + 'Backup Failed', + `Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } finally { + setIsLoading(false); + } + } + } + ] + ); + } catch (error) { + logger.error('[BackupRestoreSettings] Failed to get backup preview:', error); + openAlert( + 'Error', + 'Failed to prepare backup information. Please try again.', + [{ label: 'OK', onPress: () => {} }] + ); + setIsLoading(false); + } + }, [openAlert]); + + // Restore backup + const handleRestoreBackup = useCallback(async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/json', + copyToCacheDirectory: true, + }); + + if (result.canceled || !result.assets?.[0]) { + return; + } + + const fileUri = result.assets[0].uri; + + // Validate backup file + const backupInfo = await backupService.getBackupInfo(fileUri); + + openAlert( + 'Confirm Restore', + `This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`, + [ + { label: 'Cancel', onPress: () => {} }, + { + label: 'Restore', + onPress: async () => { + try { + setIsLoading(true); + + const restoreOptions: BackupOptions = { + includeLibrary: true, + includeWatchProgress: true, + includeDownloads: true, + includeAddons: true, + includeSettings: true, + includeTraktData: true, + includeLocalScrapers: true, + }; + + await backupService.restoreBackup(fileUri, restoreOptions); + + openAlert( + 'Restore Complete', + 'Your data has been successfully restored. Please restart the app to see all changes.', + [{ label: 'OK', onPress: () => {} }] + ); + } catch (error) { + logger.error('[BackupRestoreSettings] Failed to restore backup:', error); + openAlert( + 'Restore Failed', + `Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } finally { + setIsLoading(false); + } + } + } + ] + ); + } catch (error) { + logger.error('[BackupRestoreSettings] Failed to pick backup file:', error); + openAlert( + 'File Selection Failed', + `Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } + }, [openAlert]); + + + + const formatDate = (timestamp: number): string => { + return new Date(timestamp).toLocaleDateString() + ' ' + new Date(timestamp).toLocaleTimeString(); + }; + + return ( + + setAlertVisible(false)} + /> + + {/* Backup Actions */} + + + Backup & Restore + + + + {isLoading ? ( + + ) : ( + <> + + Create Backup + + )} + + + + + Restore from Backup + + + + + {/* Info Section */} + + + About Backups + + + • Backups include all your data: library, watch progress, settings, addons, downloads, and plugins{'\n'} + • Backup files are stored locally on your device{'\n'} + • Share your backup to transfer data between devices{'\n'} + • Restoring will overwrite your current data + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + section: { + marginHorizontal: 16, + marginVertical: 8, + borderRadius: 12, + padding: 16, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 16, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginBottom: 12, + }, + actionButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + infoText: { + fontSize: 14, + lineHeight: 20, + }, +}); + +export default BackupRestoreSettings; + diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 425e92f6..c71a8e38 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -52,11 +52,13 @@ import CastMoviesScreen from '../screens/CastMoviesScreen'; import UpdateScreen from '../screens/UpdateScreen'; import AISettingsScreen from '../screens/AISettingsScreen'; import AIChatScreen from '../screens/AIChatScreen'; +import BackupScreen from '../screens/BackupScreen'; // Stack navigator types export type RootStackParamList = { Onboarding: undefined; MainTabs: undefined; + Backup: undefined; Home: undefined; Library: undefined; Settings: undefined; @@ -1312,8 +1314,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> - + + { + const { currentTheme } = useTheme(); + const [isLoading, setIsLoading] = useState(false); + const navigation = useNavigation(); + + // Alert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + + const openAlert = ( + title: string, + message: string, + actions?: Array<{ label: string; onPress: () => void; style?: object }> + ) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertVisible(true); + }; + + // Create backup + const handleCreateBackup = useCallback(async () => { + try { + // First, get backup preview to show what will be backed up + setIsLoading(true); + const preview = await backupService.getBackupPreview(); + setIsLoading(false); + + // Calculate total without downloads + const totalWithoutDownloads = preview.library + preview.watchProgress + preview.addons + preview.scrapers; + + openAlert( + 'Create Backup', + `Backup Contents:\n\n` + + `Library: ${preview.library} items\n` + + `Watch Progress: ${preview.watchProgress} entries\n` + + `Addons: ${preview.addons} installed\n` + + `Plugins: ${preview.scrapers} configurations\n\n` + + `Total: ${totalWithoutDownloads} items\n\n` + + `This backup includes all your app settings, themes, and integration data.`, + [ + { label: 'Cancel', onPress: () => {} }, + { + label: 'Create Backup', + onPress: async () => { + try { + setIsLoading(true); + + const backupOptions: BackupOptions = { + includeLibrary: true, + includeWatchProgress: true, + includeDownloads: true, + includeAddons: true, + includeSettings: true, + includeTraktData: true, + includeLocalScrapers: true, + }; + + const fileUri = await backupService.createBackup(backupOptions); + + // Share the backup file + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(fileUri, { + mimeType: 'application/json', + dialogTitle: 'Share Nuvio Backup', + }); + } + + openAlert( + 'Backup Created', + 'Your backup has been created and is ready to share.', + [{ label: 'OK', onPress: () => {} }] + ); + } catch (error) { + logger.error('[BackupScreen] Failed to create backup:', error); + openAlert( + 'Backup Failed', + `Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } finally { + setIsLoading(false); + } + } + } + ] + ); + } catch (error) { + logger.error('[BackupScreen] Failed to get backup preview:', error); + openAlert( + 'Error', + 'Failed to prepare backup information. Please try again.', + [{ label: 'OK', onPress: () => {} }] + ); + setIsLoading(false); + } + }, [openAlert]); + + // Restore backup + const handleRestoreBackup = useCallback(async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/json', + copyToCacheDirectory: true, + }); + + if (result.canceled || !result.assets?.[0]) { + return; + } + + const fileUri = result.assets[0].uri; + + // Validate backup file + const backupInfo = await backupService.getBackupInfo(fileUri); + + openAlert( + 'Confirm Restore', + `This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`, + [ + { label: 'Cancel', onPress: () => {} }, + { + label: 'Restore', + onPress: async () => { + try { + setIsLoading(true); + + const restoreOptions: BackupOptions = { + includeLibrary: true, + includeWatchProgress: true, + includeDownloads: true, + includeAddons: true, + includeSettings: true, + includeTraktData: true, + includeLocalScrapers: true, + }; + + await backupService.restoreBackup(fileUri, restoreOptions); + + openAlert( + 'Restore Complete', + 'Your data has been successfully restored. Please restart the app to see all changes.', + [{ label: 'OK', onPress: () => {} }] + ); + } catch (error) { + logger.error('[BackupScreen] Failed to restore backup:', error); + openAlert( + 'Restore Failed', + `Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } finally { + setIsLoading(false); + } + } + } + ] + ); + } catch (error) { + logger.error('[BackupScreen] Failed to pick backup file:', error); + openAlert( + 'File Selection Failed', + `Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`, + [{ label: 'OK', onPress: () => {} }] + ); + } + }, [openAlert]); + + return ( + + + + {/* Header */} + + navigation.goBack()} + > + + Settings + + + + {/* Empty for now, but keeping structure consistent */} + + + + + Backup & Restore + + + {/* Content */} + + + setAlertVisible(false)} + /> + + {/* Backup Actions */} + + + Backup & Restore + + + + {isLoading ? ( + + ) : ( + <> + + Create Backup + + )} + + + + + Restore from Backup + + + + {/* Info Section */} + + + About Backups + + + • Backups include all your data: library, watch progress, settings, addons, downloads, and plugins{'\n'} + • Backup files are stored locally on your device{'\n'} + • Share your backup to transfer data between devices{'\n'} + • Restoring will overwrite your current data + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + scrollView: { + flex: 1, + }, + content: { + padding: 16, + }, + section: { + marginBottom: 16, + borderRadius: 12, + padding: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 16, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginBottom: 12, + }, + actionButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + infoText: { + fontSize: 14, + lineHeight: 20, + }, +}); + +export default BackupScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 29781a54..35de827e 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -49,6 +49,7 @@ const SETTINGS_CATEGORIES = [ { id: 'integrations', title: 'Integrations', icon: 'extension' as keyof typeof MaterialIcons.glyphMap }, { id: 'ai', title: 'AI Assistant', icon: 'smart-toy' as keyof typeof MaterialIcons.glyphMap }, { id: 'playback', title: 'Playback', icon: 'play-circle-outline' as keyof typeof MaterialIcons.glyphMap }, + { id: 'backup', title: 'Backup & Restore', icon: 'save' as keyof typeof MaterialIcons.glyphMap }, { id: 'updates', title: 'Updates', icon: 'system-update' as keyof typeof MaterialIcons.glyphMap }, { id: 'about', title: 'About', icon: 'info-outline' as keyof typeof MaterialIcons.glyphMap }, { id: 'developer', title: 'Developer', icon: 'code' as keyof typeof MaterialIcons.glyphMap }, @@ -197,7 +198,7 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c Settings - + {categories.map((category) => ( { return true; }); + + const renderCategoryContent = (categoryId: string) => { switch (categoryId) { case 'account': @@ -746,6 +749,21 @@ const SettingsScreen: React.FC = () => { ) : null; + case 'backup': + return ( + + navigation.navigate('Backup')} + isLast={true} + isTablet={isTablet} + /> + + ); + case 'updates': return ( @@ -881,6 +899,7 @@ const SettingsScreen: React.FC = () => { {renderCategoryContent('integrations')} {renderCategoryContent('ai')} {renderCategoryContent('playback')} + {renderCategoryContent('backup')} {renderCategoryContent('updates')} {renderCategoryContent('about')} {renderCategoryContent('developer')} diff --git a/src/services/backupService.ts b/src/services/backupService.ts new file mode 100644 index 00000000..20369df8 --- /dev/null +++ b/src/services/backupService.ts @@ -0,0 +1,666 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as FileSystem from 'expo-file-system'; +import { Platform } from 'react-native'; +import { logger } from '../utils/logger'; +import { AppSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; +import { StreamingContent } from './catalogService'; +import { DownloadItem } from '../contexts/DownloadsContext'; + +export interface BackupData { + version: string; + timestamp: number; + appVersion: string; + platform: 'ios' | 'android'; + userScope: string; + data: { + settings: AppSettings; + library: StreamingContent[]; + watchProgress: Record; + addons: any[]; + downloads: DownloadItem[]; + subtitles: any; + tombstones: Record; + continueWatchingRemoved: string[]; + contentDuration: Record; + syncQueue: any[]; + traktSettings?: any; + localScrapers?: { + scrapers: any; + repositoryUrl?: string; + repositories: any; + currentRepository?: string; + scraperSettings: any; + scraperCode: Record; + }; + customThemes?: any[]; + }; + metadata: { + totalItems: number; + libraryCount: number; + watchProgressCount: number; + downloadsCount: number; + addonsCount: number; + scrapersCount?: number; + }; +} + +export interface BackupOptions { + includeLibrary?: boolean; + includeWatchProgress?: boolean; + includeDownloads?: boolean; + includeAddons?: boolean; + includeSettings?: boolean; + includeTraktData?: boolean; + includeLocalScrapers?: boolean; +} + +export class BackupService { + private static instance: BackupService; + private readonly BACKUP_VERSION = '1.0.0'; + private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_'; + + private constructor() {} + + public static getInstance(): BackupService { + if (!BackupService.instance) { + BackupService.instance = new BackupService(); + } + return BackupService.instance; + } + + /** + * Create a comprehensive backup of all user data + */ + public async createBackup(options: BackupOptions = {}): Promise { + try { + logger.info('[BackupService] Starting backup creation...'); + + const userScope = await this.getUserScope(); + const timestamp = Date.now(); + const filename = `${this.BACKUP_FILENAME_PREFIX}${timestamp}.json`; + + // Collect all data + const backupData: BackupData = { + version: this.BACKUP_VERSION, + timestamp, + appVersion: '1.0.0', // You might want to get this from package.json + platform: Platform.OS as 'ios' | 'android', + userScope, + data: { + settings: await this.getSettings(), + library: options.includeLibrary !== false ? await this.getLibrary() : [], + watchProgress: options.includeWatchProgress !== false ? await this.getWatchProgress() : {}, + addons: options.includeAddons !== false ? await this.getAddons() : [], + downloads: options.includeDownloads !== false ? await this.getDownloads() : [], + subtitles: await this.getSubtitleSettings(), + tombstones: await this.getTombstones(), + continueWatchingRemoved: await this.getContinueWatchingRemoved(), + contentDuration: await this.getContentDuration(), + syncQueue: await this.getSyncQueue(), + traktSettings: options.includeTraktData !== false ? await this.getTraktSettings() : undefined, + localScrapers: options.includeLocalScrapers !== false ? await this.getLocalScrapers() : undefined, + }, + metadata: { + totalItems: 0, + libraryCount: 0, + watchProgressCount: 0, + downloadsCount: 0, + addonsCount: 0, + } + }; + + // Calculate metadata + backupData.metadata.libraryCount = backupData.data.library.length; + backupData.metadata.watchProgressCount = Object.keys(backupData.data.watchProgress).length; + backupData.metadata.downloadsCount = backupData.data.downloads.length; + backupData.metadata.addonsCount = backupData.data.addons.length; + + // Count scraper items if available + const scraperCount = backupData.data.localScrapers?.scrapers ? + Object.keys(backupData.data.localScrapers.scrapers).length : 0; + backupData.metadata.scrapersCount = scraperCount; + + backupData.metadata.totalItems = + backupData.metadata.libraryCount + + backupData.metadata.watchProgressCount + + backupData.metadata.downloadsCount + + backupData.metadata.addonsCount + + scraperCount; + + // Save to file + const fileUri = `${FileSystem.documentDirectory}${filename}`; + await FileSystem.writeAsStringAsync(fileUri, JSON.stringify(backupData, null, 2)); + + logger.info(`[BackupService] Backup created successfully: ${filename}`); + logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`); + + return fileUri; + } catch (error) { + logger.error('[BackupService] Failed to create backup:', error); + throw new Error(`Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Get backup preview information without creating the backup + */ + public async getBackupPreview(): Promise<{ + library: number; + watchProgress: number; + addons: number; + downloads: number; + scrapers: number; + total: number; + }> { + try { + const [ + libraryData, + watchProgressData, + addonsData, + downloadsData, + scrapersData + ] = await Promise.all([ + this.getLibrary(), + this.getWatchProgress(), + this.getAddons(), + this.getDownloads(), + this.getLocalScrapers() + ]); + + const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0; + const watchProgressCount = Object.keys(watchProgressData).length; + const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0; + const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0; + const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0; + + return { + library: libraryCount, + watchProgress: watchProgressCount, + addons: addonsCount, + downloads: downloadsCount, + scrapers: scrapersCount, + total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount + }; + } catch (error) { + logger.error('[BackupService] Failed to get backup preview:', error); + return { library: 0, watchProgress: 0, addons: 0, downloads: 0, scrapers: 0, total: 0 }; + } + } + + /** + * Restore data from a backup file + */ + public async restoreBackup(fileUri: string, options: BackupOptions = {}): Promise { + try { + logger.info('[BackupService] Starting backup restore...'); + + // Read and validate backup file + const backupContent = await FileSystem.readAsStringAsync(fileUri); + const backupData: BackupData = JSON.parse(backupContent); + + // Validate backup format + this.validateBackupData(backupData); + + logger.info(`[BackupService] Restoring backup from ${backupData.timestamp}`); + logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`); + + // Restore data based on options + if (options.includeSettings !== false) { + await this.restoreSettings(backupData.data.settings); + } + + if (options.includeLibrary !== false) { + await this.restoreLibrary(backupData.data.library); + } + + if (options.includeWatchProgress !== false) { + await this.restoreWatchProgress(backupData.data.watchProgress); + } + + if (options.includeAddons !== false) { + await this.restoreAddons(backupData.data.addons); + } + + if (options.includeDownloads !== false) { + await this.restoreDownloads(backupData.data.downloads); + } + + if (options.includeTraktData !== false && backupData.data.traktSettings) { + await this.restoreTraktSettings(backupData.data.traktSettings); + } + + if (options.includeLocalScrapers !== false && backupData.data.localScrapers) { + await this.restoreLocalScrapers(backupData.data.localScrapers); + } + + // Restore additional data + await this.restoreSubtitleSettings(backupData.data.subtitles); + await this.restoreTombstones(backupData.data.tombstones); + await this.restoreContinueWatchingRemoved(backupData.data.continueWatchingRemoved); + await this.restoreContentDuration(backupData.data.contentDuration); + await this.restoreSyncQueue(backupData.data.syncQueue); + + logger.info('[BackupService] Backup restore completed successfully'); + } catch (error) { + logger.error('[BackupService] Failed to restore backup:', error); + throw new Error(`Failed to restore backup: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Get backup file info without loading full data + */ + public async getBackupInfo(fileUri: string): Promise> { + try { + const backupContent = await FileSystem.readAsStringAsync(fileUri); + const backupData: BackupData = JSON.parse(backupContent); + + return { + version: backupData.version, + timestamp: backupData.timestamp, + appVersion: backupData.appVersion, + platform: backupData.platform, + userScope: backupData.userScope, + metadata: backupData.metadata + }; + } catch (error) { + logger.error('[BackupService] Failed to read backup info:', error); + throw new Error(`Invalid backup file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * List all backup files in the document directory + */ + public async listBackups(): Promise { + try { + const files = await FileSystem.readDirectoryAsync(FileSystem.documentDirectory!); + return files + .filter(file => file.startsWith(this.BACKUP_FILENAME_PREFIX) && file.endsWith('.json')) + .sort((a, b) => b.localeCompare(a)); // Sort by filename (newest first) + } catch (error) { + logger.error('[BackupService] Failed to list backups:', error); + return []; + } + } + + /** + * Delete a backup file + */ + public async deleteBackup(fileUri: string): Promise { + try { + await FileSystem.deleteAsync(fileUri); + logger.info('[BackupService] Backup file deleted:', fileUri); + } catch (error) { + logger.error('[BackupService] Failed to delete backup:', error); + throw new Error(`Failed to delete backup: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Private helper methods for data collection + private async getUserScope(): Promise { + try { + const scope = await AsyncStorage.getItem('@user:current'); + return scope || 'local'; + } catch { + return 'local'; + } + } + + private async getSettings(): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:app_settings`; + const settingsJson = await AsyncStorage.getItem(scopedKey); + return settingsJson ? JSON.parse(settingsJson) : DEFAULT_SETTINGS; + } catch (error) { + logger.error('[BackupService] Failed to get settings:', error); + return DEFAULT_SETTINGS; + } + } + + private async getLibrary(): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:stremio-library`; + const libraryJson = await AsyncStorage.getItem(scopedKey); + if (libraryJson) { + const parsed = JSON.parse(libraryJson); + return Array.isArray(parsed) ? parsed : Object.values(parsed); + } + return []; + } catch (error) { + logger.error('[BackupService] Failed to get library:', error); + return []; + } + } + + private async getWatchProgress(): Promise> { + try { + const scope = await this.getUserScope(); + const allKeys = await AsyncStorage.getAllKeys(); + const watchProgressKeys = allKeys.filter(key => + key.startsWith(`@user:${scope}:@watch_progress:`) + ); + + const watchProgress: Record = {}; + if (watchProgressKeys.length > 0) { + const pairs = await AsyncStorage.multiGet(watchProgressKeys); + for (const [key, value] of pairs) { + if (value) { + watchProgress[key] = JSON.parse(value); + } + } + } + return watchProgress; + } catch (error) { + logger.error('[BackupService] Failed to get watch progress:', error); + return {}; + } + } + + private async getAddons(): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:stremio-addons`; + const addonsJson = await AsyncStorage.getItem(scopedKey); + return addonsJson ? JSON.parse(addonsJson) : []; + } catch (error) { + logger.error('[BackupService] Failed to get addons:', error); + return []; + } + } + + private async getDownloads(): Promise { + try { + const downloadsJson = await AsyncStorage.getItem('downloads_state_v1'); + return downloadsJson ? JSON.parse(downloadsJson) : []; + } catch (error) { + logger.error('[BackupService] Failed to get downloads:', error); + return []; + } + } + + private async getSubtitleSettings(): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@subtitle_settings`; + const subtitlesJson = await AsyncStorage.getItem(scopedKey); + return subtitlesJson ? JSON.parse(subtitlesJson) : {}; + } catch (error) { + logger.error('[BackupService] Failed to get subtitle settings:', error); + return {}; + } + } + + private async getTombstones(): Promise> { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@wp_tombstones`; + const tombstonesJson = await AsyncStorage.getItem(scopedKey); + return tombstonesJson ? JSON.parse(tombstonesJson) : {}; + } catch (error) { + logger.error('[BackupService] Failed to get tombstones:', error); + return {}; + } + } + + private async getContinueWatchingRemoved(): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@continue_watching_removed`; + const removedJson = await AsyncStorage.getItem(scopedKey); + return removedJson ? JSON.parse(removedJson) : []; + } catch (error) { + logger.error('[BackupService] Failed to get continue watching removed:', error); + return []; + } + } + + private async getContentDuration(): Promise> { + try { + const scope = await this.getUserScope(); + const allKeys = await AsyncStorage.getAllKeys(); + const durationKeys = allKeys.filter(key => + key.startsWith(`@user:${scope}:@content_duration:`) + ); + + const contentDuration: Record = {}; + if (durationKeys.length > 0) { + const pairs = await AsyncStorage.multiGet(durationKeys); + for (const [key, value] of pairs) { + if (value) { + contentDuration[key] = JSON.parse(value); + } + } + } + return contentDuration; + } catch (error) { + logger.error('[BackupService] Failed to get content duration:', error); + return {}; + } + } + + private async getSyncQueue(): Promise { + try { + const syncQueueJson = await AsyncStorage.getItem('@sync_queue'); + return syncQueueJson ? JSON.parse(syncQueueJson) : []; + } catch (error) { + logger.error('[BackupService] Failed to get sync queue:', error); + return []; + } + } + + private async getTraktSettings(): Promise { + try { + const traktSettingsJson = await AsyncStorage.getItem('trakt_settings'); + return traktSettingsJson ? JSON.parse(traktSettingsJson) : {}; + } catch (error) { + logger.error('[BackupService] Failed to get Trakt settings:', error); + return {}; + } + } + + private async getLocalScrapers(): Promise { + try { + // Get main scraper configurations + const localScrapersJson = await AsyncStorage.getItem('local-scrapers'); + + // Get repository settings + const repoUrl = await AsyncStorage.getItem('scraper-repository-url'); + const repositories = await AsyncStorage.getItem('scraper-repositories'); + const currentRepo = await AsyncStorage.getItem('current-repository-id'); + const scraperSettings = await AsyncStorage.getItem('scraper-settings'); + + // Get all scraper code cache keys + const allKeys = await AsyncStorage.getAllKeys(); + const scraperCodeKeys = allKeys.filter(key => key.startsWith('scraper-code-')); + const scraperCode: Record = {}; + + if (scraperCodeKeys.length > 0) { + const codePairs = await AsyncStorage.multiGet(scraperCodeKeys); + for (const [key, value] of codePairs) { + if (value) { + scraperCode[key] = value; + } + } + } + + return { + scrapers: localScrapersJson ? JSON.parse(localScrapersJson) : {}, + repositoryUrl: repoUrl, + repositories: repositories ? JSON.parse(repositories) : {}, + currentRepository: currentRepo, + scraperSettings: scraperSettings ? JSON.parse(scraperSettings) : {}, + scraperCode: scraperCode + }; + } catch (error) { + logger.error('[BackupService] Failed to get local scrapers:', error); + return {}; + } + } + + // Private helper methods for data restoration + private async restoreSettings(settings: AppSettings): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:app_settings`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(settings)); + logger.info('[BackupService] Settings restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore settings:', error); + } + } + + private async restoreLibrary(library: StreamingContent[]): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:stremio-library`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(library)); + logger.info('[BackupService] Library restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore library:', error); + } + } + + private async restoreWatchProgress(watchProgress: Record): Promise { + try { + const pairs: [string, string][] = Object.entries(watchProgress).map(([key, value]) => [key, JSON.stringify(value)]); + await AsyncStorage.multiSet(pairs); + logger.info('[BackupService] Watch progress restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore watch progress:', error); + } + } + + private async restoreAddons(addons: any[]): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:stremio-addons`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(addons)); + logger.info('[BackupService] Addons restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore addons:', error); + } + } + + private async restoreDownloads(downloads: DownloadItem[]): Promise { + try { + await AsyncStorage.setItem('downloads_state_v1', JSON.stringify(downloads)); + logger.info('[BackupService] Downloads restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore downloads:', error); + } + } + + private async restoreSubtitleSettings(subtitles: any): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@subtitle_settings`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(subtitles)); + logger.info('[BackupService] Subtitle settings restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore subtitle settings:', error); + } + } + + private async restoreTombstones(tombstones: Record): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@wp_tombstones`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(tombstones)); + logger.info('[BackupService] Tombstones restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore tombstones:', error); + } + } + + private async restoreContinueWatchingRemoved(removed: string[]): Promise { + try { + const scope = await this.getUserScope(); + const scopedKey = `@user:${scope}:@continue_watching_removed`; + await AsyncStorage.setItem(scopedKey, JSON.stringify(removed)); + logger.info('[BackupService] Continue watching removed restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore continue watching removed:', error); + } + } + + private async restoreContentDuration(contentDuration: Record): Promise { + try { + const pairs: [string, string][] = Object.entries(contentDuration).map(([key, value]) => [key, JSON.stringify(value)]); + await AsyncStorage.multiSet(pairs); + logger.info('[BackupService] Content duration restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore content duration:', error); + } + } + + private async restoreSyncQueue(syncQueue: any[]): Promise { + try { + await AsyncStorage.setItem('@sync_queue', JSON.stringify(syncQueue)); + logger.info('[BackupService] Sync queue restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore sync queue:', error); + } + } + + private async restoreTraktSettings(traktSettings: any): Promise { + try { + await AsyncStorage.setItem('trakt_settings', JSON.stringify(traktSettings)); + logger.info('[BackupService] Trakt settings restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore Trakt settings:', error); + } + } + + private async restoreLocalScrapers(localScrapers: any): Promise { + try { + // Restore main scraper configurations + if (localScrapers.scrapers) { + await AsyncStorage.setItem('local-scrapers', JSON.stringify(localScrapers.scrapers)); + } + + // Restore repository settings + if (localScrapers.repositoryUrl) { + await AsyncStorage.setItem('scraper-repository-url', localScrapers.repositoryUrl); + } + + if (localScrapers.repositories) { + await AsyncStorage.setItem('scraper-repositories', JSON.stringify(localScrapers.repositories)); + } + + if (localScrapers.currentRepository) { + await AsyncStorage.setItem('current-repository-id', localScrapers.currentRepository); + } + + if (localScrapers.scraperSettings) { + await AsyncStorage.setItem('scraper-settings', JSON.stringify(localScrapers.scraperSettings)); + } + + // Restore scraper code cache + if (localScrapers.scraperCode && typeof localScrapers.scraperCode === 'object') { + const codePairs: [string, string][] = Object.entries(localScrapers.scraperCode).map(([key, value]) => [key, value as string]); + if (codePairs.length > 0) { + await AsyncStorage.multiSet(codePairs); + } + } + + logger.info('[BackupService] Local scrapers and plugin settings restored'); + } catch (error) { + logger.error('[BackupService] Failed to restore local scrapers:', error); + } + } + + private validateBackupData(backupData: any): void { + if (!backupData.version || !backupData.timestamp || !backupData.data) { + throw new Error('Invalid backup file format'); + } + + if (backupData.version !== this.BACKUP_VERSION) { + throw new Error(`Unsupported backup version: ${backupData.version}`); + } + } +} + +export const backupService = BackupService.getInstance();