From 18075308dd88a4c77d3848a6327b101ebe545dff Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sun, 25 May 2025 13:20:24 +0200 Subject: [PATCH] test backup system --- Sora/Utils/Backups/BackupData.swift | 115 +++++++++++++++++ Sora/Utils/Backups/BackupManager.swift | 88 +++++++++++++ Sora/Utils/Extensions/DateFormatter.swift | 16 +++ Sora/Utils/Extensions/Notification+Name.swift | 1 + .../SettingsSubViews/SettingsViewData.swift | 118 +++++++++++++++--- Sulfur.xcodeproj/project.pbxproj | 22 +++- 6 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 Sora/Utils/Backups/BackupData.swift create mode 100644 Sora/Utils/Backups/BackupManager.swift create mode 100644 Sora/Utils/Extensions/DateFormatter.swift diff --git a/Sora/Utils/Backups/BackupData.swift b/Sora/Utils/Backups/BackupData.swift new file mode 100644 index 0000000..2f30dd8 --- /dev/null +++ b/Sora/Utils/Backups/BackupData.swift @@ -0,0 +1,115 @@ +// +// BackupData.swift +// Sulfur +// +// Created by Francesco on 25/05/25. +// + +import Foundation +import SwiftUI + +struct BackupData: Codable { + let version: String + let timestamp: Date + let userData: [String: Any] + + init(userData: [String: Any]) { + self.version = "1.0" + self.timestamp = Date() + self.userData = userData + } + + enum CodingKeys: String, CodingKey { + case version, timestamp, userData + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decode(String.self, forKey: .version) + timestamp = try container.decode(Date.self, forKey: .timestamp) + + let userDataContainer = try container.nestedContainer(keyedBy: DynamicKey.self, forKey: .userData) + var userData: [String: Any] = [:] + + for key in userDataContainer.allKeys { + userData[key.stringValue] = try userDataContainer.decode(AnyCodable.self, forKey: key).value + } + + self.userData = userData + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(version, forKey: .version) + try container.encode(timestamp, forKey: .timestamp) + + var userDataContainer = container.nestedContainer(keyedBy: DynamicKey.self, forKey: .userData) + for (key, value) in userData { + let dynamicKey = DynamicKey(stringValue: key)! + try userDataContainer.encode(AnyCodable(value), forKey: dynamicKey) + } + } +} + +struct DynamicKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + self.intValue = intValue + self.stringValue = String(intValue) + } +} + +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dictionary as [String: Any]: + try container.encode(dictionary.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unsupported type")) + } + } +} diff --git a/Sora/Utils/Backups/BackupManager.swift b/Sora/Utils/Backups/BackupManager.swift new file mode 100644 index 0000000..3a3f377 --- /dev/null +++ b/Sora/Utils/Backups/BackupManager.swift @@ -0,0 +1,88 @@ +// +// BackupManager.swift +// Sulfur +// +// Created by Francesco on 25/05/25. +// + +import Foundation + +class BackupManager: ObservableObject { + static let shared = BackupManager() + + private init() {} + + func createBackup() -> BackupData { + var userData: [String: Any] = [:] + + let userDefaults = UserDefaults.standard + let defaultsDict = userDefaults.dictionaryRepresentation() + + let appKeys = defaultsDict.keys.filter { key in + !key.hasPrefix("Apple") && + !key.hasPrefix("NS") && + !key.hasPrefix("com.apple") && + !key.contains("Keyboard") + } + + for key in appKeys { + userData[key] = defaultsDict[key] + } + + return BackupData(userData: userData) + } + + func exportBackup() -> URL? { + let backup = createBackup() + + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + + let data = try encoder.encode(backup) + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fileName = "sora_backup_\(DateFormatter.backupFormatter.string(from: Date())).json" + let fileURL = documentsPath.appendingPathComponent(fileName) + + try data.write(to: fileURL) + return fileURL + + } catch { + print("Failed to export backup: \(error)") + return nil + } + } + + func importBackup(from url: URL) -> Bool { + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let backup = try decoder.decode(BackupData.self, from: data) + + let userDefaults = UserDefaults.standard + for (key, value) in backup.userData { + userDefaults.set(value, forKey: key) + } + + userDefaults.synchronize() + + + NotificationCenter.default.post(name: .backupRestored, object: nil) + + return true + + } catch { + print("Failed to import backup: \(error)") + return false + } + } + + func shareBackup() -> URL? { + return exportBackup() + } +} + diff --git a/Sora/Utils/Extensions/DateFormatter.swift b/Sora/Utils/Extensions/DateFormatter.swift new file mode 100644 index 0000000..a82db22 --- /dev/null +++ b/Sora/Utils/Extensions/DateFormatter.swift @@ -0,0 +1,16 @@ +// +// DateFormatter.swift +// Sulfur +// +// Created by Francesco on 25/05/25. +// + +import Foundation + +extension DateFormatter { + static let backupFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + return formatter + }() +} diff --git a/Sora/Utils/Extensions/Notification+Name.swift b/Sora/Utils/Extensions/Notification+Name.swift index 2e87c5c..6f5d6a7 100644 --- a/Sora/Utils/Extensions/Notification+Name.swift +++ b/Sora/Utils/Extensions/Notification+Name.swift @@ -13,4 +13,5 @@ extension Notification.Name { static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate") static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate") static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete") + static let backupRestored = Notification.Name("backupRestored") } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index e6a5b19..1804f20 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UniformTypeIdentifiers struct SettingsViewData: View { @State private var showEraseAppDataAlert = false @@ -18,14 +19,41 @@ struct SettingsViewData: View { @State private var movPkgSize: Int64 = 0 @State private var showRemoveMovPkgAlert = false - // State bindings for cache settings @State private var isMetadataCachingEnabled: Bool = true @State private var isImageCachingEnabled: Bool = true @State private var isMemoryOnlyMode: Bool = false + @StateObject private var backupManager = BackupManager.shared + @State private var showingExportSuccess = false + @State private var showingImportSuccess = false + @State private var showingError = false + @State private var errorMessage = "" + @State private var showingFilePicker = false + @State private var showingShareSheet = false + @State private var backupURL: URL? + var body: some View { Form { - // New section for cache settings + Section(header: Text("Backup & Restore"), footer: Text("Create backups to transfer your data to another device or restore from a previous backup.")) { + Button(action: exportBackup) { + HStack { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.blue) + Text("Create Backup") + Spacer() + } + } + + Button(action: { showingFilePicker = true }) { + HStack { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.green) + Text("Restore from Backup") + Spacer() + } + } + } + Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) { Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled) .onChange(of: isMetadataCachingEnabled) { newValue in @@ -48,7 +76,6 @@ struct SettingsViewData: View { .onChange(of: isMemoryOnlyMode) { newValue in MetadataCacheManager.shared.isMemoryOnlyMode = newValue if newValue { - // Clear disk cache when switching to memory-only MetadataCacheManager.shared.clearAllCache() calculateCacheSize() } @@ -118,13 +145,42 @@ struct SettingsViewData: View { .navigationTitle("App Data") .navigationViewStyle(StackNavigationViewStyle()) .onAppear { - // Initialize state with current values isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode calculateCacheSize() updateSizes() } + .fileImporter( + isPresented: $showingFilePicker, + allowedContentTypes: [UTType.json], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .sheet(isPresented: $showingShareSheet) { + if let url = backupURL { + ShareSheet(items: [url]) + } + } + .alert("Backup Created", isPresented: $showingExportSuccess) { + Button("Share") { + showingShareSheet = true + } + Button("OK") { } + } message: { + Text("Your backup has been created successfully. You can share it or find it in your Files app.") + } + .alert("Backup Restored", isPresented: $showingImportSuccess) { + Button("OK") { } + } message: { + Text("Your data has been restored successfully. The app will refresh with your restored settings.") + } + .alert("Error", isPresented: $showingError) { + Button("OK") { } + } message: { + Text(errorMessage) + } .alert(isPresented: $showEraseAppDataAlert) { Alert( title: Text("Erase App Data"), @@ -157,24 +213,51 @@ struct SettingsViewData: View { } } - // Calculate and update the combined cache size + private func exportBackup() { + guard let url = backupManager.exportBackup() else { + errorMessage = "Failed to create backup file" + showingError = true + return + } + + backupURL = url + showingExportSuccess = true + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + if backupManager.importBackup(from: url) { + showingImportSuccess = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled + isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled + isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode + } + } else { + errorMessage = "Failed to restore backup. Please check if the file is a valid Sora backup." + showingError = true + } + + case .failure(let error): + errorMessage = "Failed to read backup file: \(error.localizedDescription)" + showingError = true + } + } func calculateCacheSize() { isCalculatingSize = true cacheSizeText = "Calculating..." - // Group all cache size calculations DispatchQueue.global(qos: .background).async { var totalSize: Int64 = 0 - - // Get metadata cache size let metadataSize = MetadataCacheManager.shared.getCacheSize() totalSize += metadataSize - // Get image cache size asynchronously KingfisherCacheManager.shared.calculateCacheSize { imageSize in totalSize += Int64(imageSize) - // Update the UI on the main thread DispatchQueue.main.async { self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize)) self.isCalculatingSize = false @@ -183,14 +266,9 @@ struct SettingsViewData: View { } } - // Clear all caches (both metadata and images) func clearAllCaches() { - // Clear metadata cache MetadataCacheManager.shared.clearAllCache() - - // Clear image cache KingfisherCacheManager.shared.clearCache { - // Update cache size after clearing calculateCacheSize() } @@ -314,3 +392,13 @@ struct SettingsViewData: View { return totalSize } } + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index c4553d9..8cb6030 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; }; 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; }; 132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */; }; + 132FC5BB2DE333D3009A80F7 /* BackupData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BA2DE333D3009A80F7 /* BackupData.swift */; }; + 132FC5BD2DE333F4009A80F7 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */; }; + 132FC5BF2DE33410009A80F7 /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BE2DE33410009A80F7 /* BackupManager.swift */; }; 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; }; 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; }; 133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; }; @@ -98,6 +101,9 @@ 132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = ""; }; 132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; }; 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = ""; }; + 132FC5BA2DE333D3009A80F7 /* BackupData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupData.swift; sourceTree = ""; }; + 132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; + 132FC5BE2DE33410009A80F7 /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = ""; }; 133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; }; 133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; }; 133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -213,6 +219,15 @@ path = Analytics; sourceTree = ""; }; + 132FC5B92DE333BD009A80F7 /* Backups */ = { + isa = PBXGroup; + children = ( + 132FC5BA2DE333D3009A80F7 /* BackupData.swift */, + 132FC5BE2DE33410009A80F7 /* BackupManager.swift */, + ); + path = Backups; + sourceTree = ""; + }; 133D7C612D2BE2500075467E = { isa = PBXGroup; children = ( @@ -294,9 +309,10 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( - 7205AEDA2DCCEF9500943F3F /* Cache */, 13D842532D45266900EBBFA6 /* Drops */, + 7205AEDA2DCCEF9500943F3F /* Cache */, 1399FAD12D3AB33D00E97C31 /* Logger */, + 132FC5B92DE333BD009A80F7 /* Backups */, 133D7C882D2BE2640075467E /* Modules */, 133D7C8A2D2BE2640075467E /* JSLoader */, 1327FBA52D758CEA00FC6689 /* Analytics */, @@ -315,6 +331,7 @@ 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */, 136BBE7F2DB1038000906B5E /* Notification+Name.swift */, 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */, + 132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */, 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */, 133D7C872D2BE2640075467E /* URLSession.swift */, 1359ED132D76F49900C13034 /* finTopView.swift */, @@ -620,6 +637,7 @@ 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, + 132FC5BF2DE33410009A80F7 /* BackupManager.swift in Sources */, 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */, 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, @@ -662,6 +680,8 @@ 72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */, 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, + 132FC5BB2DE333D3009A80F7 /* BackupData.swift in Sources */, + 132FC5BD2DE333F4009A80F7 /* DateFormatter.swift in Sources */, 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,