diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index c70dcb98..371531fe 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0A2C285E45007E03CA /* LogDetail.swift */; }; DDD5BB0D2C285F00007E03CA /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0C2C285F00007E03CA /* Logger.swift */; }; DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */; }; + DDD5BB162C28B1E4007E03CA /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB152C28B1E4007E03CA /* AppData.swift */; }; DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD6EEAE29BC024700383354 /* Firmware.swift */; }; DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */; }; DDD9E4E4284B208E003777C5 /* UserEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */; }; @@ -448,6 +449,7 @@ DDD5BB0C2C285F00007E03CA /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogFilter.swift; sourceTree = ""; }; DDD5BB142C28680D007E03CA /* MeshtasticDataModelV 38.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 38.xcdatamodel"; sourceTree = ""; }; + DDD5BB152C28B1E4007E03CA /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = ""; }; DDD6EEAE29BC024700383354 /* Firmware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Firmware.swift; sourceTree = ""; }; DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeText.swift; sourceTree = ""; }; DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityExtension.swift; sourceTree = ""; }; @@ -614,6 +616,8 @@ DDD5BB0E2C285F92007E03CA /* Logs */, DD93800C2BA74CE3008BEC06 /* Channels */, DD97E96728EFE9A00056DDA4 /* About.swift */, + DDD5BB152C28B1E4007E03CA /* AppData.swift */, + DDD5BB082C285DDC007E03CA /* AppLog.swift */, DD4A911D2708C65400501B7E /* AppSettings.swift */, DDAB580C2B0DAA9E00147258 /* Routes.swift */, DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */, @@ -627,7 +631,6 @@ DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, DD61937A2863876A00E59241 /* Config */, DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */, - DDD5BB082C285DDC007E03CA /* AppLog.swift */, ); path = Settings; sourceTree = ""; @@ -1318,6 +1321,7 @@ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, DD5E520D298EE33B00D21B61 /* storeforward.pb.swift in Sources */, + DDD5BB162C28B1E4007E03CA /* AppData.swift in Sources */, DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */, diff --git a/Meshtastic/Extensions/Url.swift b/Meshtastic/Extensions/Url.swift index 6d594dd9..b890e498 100644 --- a/Meshtastic/Extensions/Url.swift +++ b/Meshtastic/Extensions/Url.swift @@ -30,4 +30,24 @@ extension URL { return nil } } + var attributes: [FileAttributeKey: Any]? { + do { + return try FileManager.default.attributesOfItem(atPath: path) + } catch let error as NSError { + print("FileAttribute error: \(error)") + } + return nil + } + + var fileSize: UInt64 { + return attributes?[.size] as? UInt64 ?? UInt64(0) + } + + var fileSizeString: String { + return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } + + var creationDate: Date? { + return attributes?[.creationDate] as? Date + } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 87e33b4a..79bba86c 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -637,6 +637,27 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate connectedPeripheral.num = myInfo?.myNodeNum ?? 0 connectedPeripheral.name = myInfo?.bleName ?? "unknown".localized connectedPeripheral.longName = myInfo?.bleName ?? "unknown".localized + let newConnection = Int64(UserDefaults.preferredPeripheralNum) != Int64(decodedInfo.myInfo.myNodeNum) + if newConnection { + let container = NSPersistentContainer(name: "Meshtastic") + if let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let databasePath = url.appendingPathComponent("backup") + .appendingPathComponent("\(UserDefaults.preferredPeripheralNum)") + .appendingPathComponent("Meshtastic.sqlite") + if FileManager.default.fileExists(atPath: databasePath.path) { + do { + disconnectPeripheral(reconnect: false) + try container.restorePersistentStore(from: databasePath) + UserDefaults.preferredPeripheralNum = Int(myInfo?.myNodeNum ?? 0) + context!.reset() + connectTo(peripheral: peripheral) + Logger.data.notice("🗂️ Restored Core data for /\(UserDefaults.preferredPeripheralNum)") + } catch { + Logger.data.error("Copy error: \(error)") + } + } + } + } } tryClearExistingChannels() } diff --git a/Meshtastic/Persistence/Persistence.swift b/Meshtastic/Persistence/Persistence.swift index 0da6a264..92a2e381 100644 --- a/Meshtastic/Persistence/Persistence.swift +++ b/Meshtastic/Persistence/Persistence.swift @@ -89,3 +89,131 @@ extension NSManagedObjectContext { NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self]) } } + + +// Created by Tom Harrington on 5/12/20. +// Copyright © 2020 Atomic Bird LLC. All rights reserved. +// Gist from https://atomicbird.com/blog/core-data-back-up-store/ +// +extension NSPersistentContainer { + + enum CopyPersistentStoreErrors: Error { + case invalidDestination(String) + case destinationError(String) + case destinationNotRemoved(String) + case copyStoreError(String) + case invalidSource(String) + } + + /// Restore a persistent store for a URL `backupURL`. + /// + /// **Be very careful with this**. To restore a persistent store, the current persistent store must be removed from the container. When that happens, **all currently loaded Core Data objects** will become invalid. Using them after restoring will cause your app to crash. When calling this method you **must** ensure that you do not continue to use any previously fetched managed objects or existing fetched results controllers. **If this method does not throw, that does not mean your app is safe.** You need to take extra steps to prevent crashes. The details vary depending on the nature of your app. + /// - Parameter backupURL: A file URL containing backup copies of all currently loaded persistent stores. + /// - Throws: `CopyPersistentStoreError` in various situations. + /// - Returns: Nothing. If no errors are thrown, the restore is complete. + func restorePersistentStore(from backupURL: URL) throws -> Void { + guard backupURL.isFileURL else { + throw CopyPersistentStoreErrors.invalidSource("Backup URL must be a file URL") + } + + for persistentStoreDescription in persistentStoreDescriptions { + guard let loadedStoreURL = persistentStoreDescription.url else { + continue + } + guard FileManager.default.fileExists(atPath: backupURL.path) else { + throw CopyPersistentStoreErrors.invalidSource("Missing backup store for \(backupURL)") + } + do { + let storeOptions = persistentStoreDescription.options + let configurationName = persistentStoreDescription.configuration + let storeType = persistentStoreDescription.type + + // Replace the current store with the backup copy. This has a side effect of removing the current store from the Core Data stack. + // When restoring, it's necessary to use the current persistent store coordinator. + try persistentStoreCoordinator.replacePersistentStore(at: loadedStoreURL, destinationOptions: storeOptions, withPersistentStoreFrom: backupURL, sourceOptions: storeOptions, ofType: storeType) + // Add the persistent store at the same location we've been using, because it was removed in the previous step. + try persistentStoreCoordinator.addPersistentStore(ofType: storeType, configurationName: configurationName, at: loadedStoreURL, options: storeOptions) + } catch { + throw CopyPersistentStoreErrors.copyStoreError("Could not restore: \(error.localizedDescription)") + } + } + } + + /// Copy all loaded persistent stores to a new directory. Each currently loaded file-based persistent store will be copied (including journal files, external binary storage, and anything else Core Data needs) into the destination directory to a persistent store with the same name and type as the existing store. In-memory stores, if any, are skipped. + /// - Parameters: + /// - destinationURL: Destination for new persistent store files. Must be a file URL. If `overwriting` is `false` and `destinationURL` exists, it must be a directory. + /// - overwriting: If `true`, any existing copies of the persistent store will be replaced or updated. If `false`, existing copies will not be changed or remoted. When this is `false`, the destination persistent store file must not already exist. + /// - Throws: `CopyPersistentStoreError` + /// - Returns: Nothing. If no errors are thrown, all loaded persistent stores will be copied to the destination directory. + func copyPersistentStores(to destinationURL: URL, overwriting: Bool = false) throws -> Void { + print(destinationURL) + guard !destinationURL.relativeString.contains("/0/") else { + throw CopyPersistentStoreErrors.invalidDestination("Invalid 0 Node Id") + } + + guard destinationURL.isFileURL else { + throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a file URL") + } + + // If the destination exists and we aren't overwriting it, then it must be a directory. (If we are overwriting, we'll remove it anyway, so it doesn't matter whether it's a directory). + var isDirectory: ObjCBool = false + if !overwriting && FileManager.default.fileExists(atPath: destinationURL.path, isDirectory: &isDirectory) { + if !isDirectory.boolValue { + throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a directory") + } + // Don't check if destination stores exist in the destination dir, that comes later on a per-store basis. + } + // If we're overwriting, remove the destination. + if overwriting && FileManager.default.fileExists(atPath: destinationURL.path) { + do { + try FileManager.default.removeItem(at: destinationURL) + } catch { + throw CopyPersistentStoreErrors.destinationNotRemoved("Can't overwrite destination at \(destinationURL)") + } + } + + // Create the destination directory + do { + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) + } catch { + throw CopyPersistentStoreErrors.destinationError("Could not create destination directory at \(destinationURL)") + } + + for persistentStoreDescription in persistentStoreDescriptions { + guard let storeURL = persistentStoreDescription.url else { + continue + } + guard persistentStoreDescription.type != NSInMemoryStoreType else { + continue + } + let temporaryPSC = NSPersistentStoreCoordinator(managedObjectModel: persistentStoreCoordinator.managedObjectModel) + let destinationStoreURL = destinationURL.appendingPathComponent(storeURL.lastPathComponent) + if !overwriting && FileManager.default.fileExists(atPath: destinationStoreURL.path) { + // If the destination exists, the migratePersistentStore call will update it in place. That's fine unless we're not overwriting. + throw CopyPersistentStoreErrors.destinationError("Destination already exists at \(destinationStoreURL)") + } + do { + let newStore = try temporaryPSC.addPersistentStore(ofType: persistentStoreDescription.type, configurationName: persistentStoreDescription.configuration, at: persistentStoreDescription.url, options: persistentStoreDescription.options) + let _ = try temporaryPSC.migratePersistentStore(newStore, to: destinationStoreURL, options: persistentStoreDescription.options, withType: persistentStoreDescription.type) + + /// Cleanup extra files + let directory = destinationStoreURL.deletingLastPathComponent() + /// Delete -wal file + do { + try FileManager.default.removeItem(at: directory.appendingPathComponent("Meshtastic.sqlite-wal")) + /// Delete -shm file + do { + try FileManager.default.removeItem(at: directory.appendingPathComponent("Meshtastic.sqlite-shm")) + } catch { + print(error) + } + } catch { + print(error) + } + + } catch { + throw CopyPersistentStoreErrors.copyStoreError("\(error.localizedDescription)") + } + } + } +} diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 63537d86..a7d8ca71 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -26,7 +26,6 @@ struct Connect: View { @State var isUnsetRegion = false @State var invalidFirmwareVersion = false @State var liveActivityStarted = false - @State var presentingSwitchPreferredPeripheral = false @State var selectedPeripherialId = "" init () { @@ -49,7 +48,7 @@ struct Connect: View { List { if bleManager.isSwitchedOn { Section(header: Text("connected.radio").font(.title)) { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == .connected { + if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == .connected { if #available(iOS 17.0, macOS 14.0, *) { TipView(BluetoothConnectionTip(), arrowEdge: .bottom) } @@ -60,9 +59,9 @@ struct Connect: View { .padding(.trailing) VStack(alignment: .leading) { if node != nil { - Text(bleManager.connectedPeripheral.longName).font(.title2) + Text(connectedPeripheral.longName).font(.title2) } - Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral.peripheral.name ?? "unknown".localized)") + Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name ?? "unknown".localized)") .font(.callout).foregroundColor(Color.gray) if node != nil { Text("firmware.version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "unknown".localized)") @@ -91,7 +90,8 @@ struct Connect: View { .padding([.top, .bottom]) .swipeActions { Button(role: .destructive) { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { + if let connectedPeripheral = bleManager.connectedPeripheral, + connectedPeripheral.peripheral.state == .connected { bleManager.disconnectPeripheral(reconnect: false) } } label: { @@ -121,7 +121,8 @@ struct Connect: View { Text("Num: \(String(node!.num))") Text("Short Name: \(node?.user?.shortName ?? "?")") Text("Long Name: \(node?.user?.longName ?? "unknown".localized)") - Text("BLE RSSI: \(bleManager.connectedPeripheral.rssi)") + Text("BLE RSSI: \(connectedPeripheral.rssi)") + Button { if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!, adminIndex: node!.myInfo!.adminIndex) { Logger.mesh.error("Shutdown Failed") @@ -210,8 +211,27 @@ struct Connect: View { } Button(action: { if UserDefaults.preferredPeripheralId.count > 0 && peripheral.peripheral.identifier.uuidString != UserDefaults.preferredPeripheralId { - presentingSwitchPreferredPeripheral = true - selectedPeripherialId = peripheral.peripheral.identifier.uuidString + if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == CBPeripheralState.connected { + bleManager.disconnectPeripheral() + } + //clearCoreDataDatabase(context: context, includeRoutes: false) + let container = NSPersistentContainer(name : "Meshtastic") + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("nil File path for back") + return + } + do { + try container.copyPersistentStores(to: url.appendingPathComponent("backup").appendingPathComponent("\(UserDefaults.preferredPeripheralNum)"), overwriting: true) + + Logger.data.notice("🗂️ Made a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + UserDefaults.preferredPeripheralId = selectedPeripherialId + let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId }) + if radio != nil { + bleManager.connectTo(peripheral: radio!.peripheral) + } } else { self.bleManager.connectTo(peripheral: peripheral.peripheral) } @@ -225,23 +245,6 @@ struct Connect: View { }.padding([.bottom, .top]) } } - .confirmationDialog("Connecting to a new radio will clear all local app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { - - Button("Connect to new radio?", role: .destructive) { - UserDefaults.preferredPeripheralId = selectedPeripherialId - UserDefaults.preferredPeripheralNum = 0 - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { - bleManager.disconnectPeripheral() - } - clearCoreDataDatabase(context: context, includeRoutes: false) - - let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId }) - if radio != nil { - bleManager.connectTo(peripheral: radio!.peripheral) - } - } - } - .textCase(nil) } } else { @@ -254,9 +257,9 @@ struct Connect: View { HStack(alignment: .center) { Spacer() #if targetEnvironment(macCatalyst) - if bleManager.connectedPeripheral != nil { + if let connectedPeripheral = bleManager.connectedPeripheral { Button(role: .destructive, action: { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral.peripheral.state == CBPeripheralState.connected { bleManager.disconnectPeripheral(reconnect: false) } }) { @@ -285,10 +288,18 @@ struct Connect: View { .padding(.bottom, 10) } .navigationTitle("bluetooth") - .navigationBarItems(leading: MeshtasticLogo(), trailing: - ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", mqttProxyConnected: bleManager.mqttProxyConnected, mqttTopic: bleManager.mqttManager.topic) - }) + .navigationBarItems( + leading: MeshtasticLogo(), + trailing: ZStack { + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: bleManager.connectedPeripheral?.shortName ?? "?", + mqttProxyConnected: bleManager.mqttProxyConnected, + mqttTopic: bleManager.mqttManager.topic + ) + } + ) } .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) { InvalidVersion(minimumVersion: self.bleManager.minimumVersion, version: self.bleManager.connectedVersion) @@ -302,24 +313,18 @@ struct Connect: View { if UserDefaults.preferredPeripheralId.count > 0 && sub { - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) do { - guard let fetchedNode = try context.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] else { - return - } - // Found a node, check it for a region - if !fetchedNode.isEmpty { - node = fetchedNode[0] - if node!.loRaConfig != nil && node!.loRaConfig?.regionCode ?? 0 == RegionCodes.unset.rawValue { - isUnsetRegion = true - } else { - isUnsetRegion = false - } + node = try context.fetch(fetchNodeInfoRequest).first + if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue { + isUnsetRegion = true + } else { + isUnsetRegion = false } } catch { - + Logger.data.error("💥 Error fetching node info: \(error.localizedDescription)") } } } diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift new file mode 100644 index 00000000..631cccf4 --- /dev/null +++ b/Meshtastic/Views/Settings/AppData.swift @@ -0,0 +1,157 @@ +// +// BackupData.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/8/24. +// + +import SwiftUI +import OSLog +import CoreData +import Foundation + +struct AppData: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + @State private var files = [URL]() + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + var body: some View { + + + VStack { + Button(action: { + let container = NSPersistentContainer(name : "Meshtastic") + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("nil File path for back") + return + } + do { + try container.copyPersistentStores(to: url.appendingPathComponent("backup").appendingPathComponent("\(UserDefaults.preferredPeripheralNum)"), overwriting: true) + loadFiles() + Logger.data.notice("🗂️ Made a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + + }) { + Label { + Text("Backup Database") + .font(idiom == .phone ? .callout : .title) + } icon: { + Image(systemName: "cylinder.split.1x2") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + + } + List(files, id: \.self) { file in + HStack { + VStack (alignment: .leading ) { + if file.pathExtension.contains("sqlite") { //} == "sqlite" { + Label { + Text("Node Core Data Backup \(file.pathComponents[9])/\(file.lastPathComponent) - \(file.creationDate?.formatted() ?? "") - \(file.fileSizeString)") + .swipeActions { + Button(role: .none) { + bleManager.disconnectPeripheral(reconnect: false) + let container = NSPersistentContainer(name : "Meshtastic") + do { + context.reset() + try container.restorePersistentStore(from: file.absoluteURL) + UserDefaults.preferredPeripheralId = "" + UserDefaults.preferredPeripheralNum = Int(file.pathComponents[10]) ?? 0 + Logger.data.notice("🗂️ Restored a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + } label: { + Label("restore", systemImage: "arrow.counterclockwise") + } + Button(role: .destructive) { + do { + try FileManager.default.removeItem(at: file) + } catch { + print(error) + } + } label: { + Label("delete", systemImage: "trash") + } + } + } icon: { + Image(systemName: "cylinder.split.1x2") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + else { + Label { + Text("\(file.lastPathComponent) - \(file.creationDate?.formatted() ?? "") - \(file.fileSizeString)") + .swipeActions { + Button(role: .destructive) { + do { + try FileManager.default.removeItem(at: file) + } catch { + print(error) + } + } label: { + Label("delete", systemImage: "trash") + } + } + + } icon: { + Image(systemName: "doc.text") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + } +#if targetEnvironment(macCatalyst) + Spacer() + VStack (alignment: .trailing) { + Button() { + do { + try FileManager.default.removeItem(at: file) + loadFiles() + } catch { + print(error) + } + } label: { + Label("", systemImage: "trash") + } + } +#endif + } + } + .navigationBarTitle("File Storage", displayMode: .inline) + .onAppear(perform: { + loadFiles() + }) + .listStyle(.inset) + } + + private func loadFiles() { + files = [] + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("🗂️ nil default document directory path for backup, core data backup failed.") + return + } + if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey]) + if fileAttributes.isRegularFile! { + files.append(fileURL) + } + } catch { print(error, fileURL) } + } + } + } +} diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 7eb03933..528843af 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -76,6 +76,26 @@ struct AppSettings: View { ) { Button("Erase all app data?", role: .destructive) { bleManager.disconnectPeripheral() + /// Delete any database backups too + if var url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + url = url.appendingPathComponent("backup").appendingPathComponent(String(UserDefaults.preferredPeripheralNum)) + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite")) + /// Delete -shm file + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-wal")) + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-shm")) + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite-shm file \(error)") + } + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite-wal file \(error)") + } + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite file \(error)") + } + } clearCoreDataDatabase(context: context, includeRoutes: true) context.refreshAllObjects() UserDefaults.standard.reset() diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 2817e8b6..394bc941 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -49,6 +49,7 @@ struct Settings: View { case adminMessageLog case about case appLog + case appData } var body: some View { NavigationSplitView { @@ -426,6 +427,20 @@ struct Settings: View { .tag(SettingsSidebar.appLog) } } +#if DEBUG + Section(header: Text("Developers")) { + NavigationLink { + AppData() + } label: { + Label { + Text("App Files") + } icon: { + Image(systemName: "folder") + } + } + .tag(SettingsSidebar.appData) + } +#endif Section(header: Text("Firmware")) { NavigationLink { Firmware(node: nodes.first(where: { $0.num == preferredNodeNum }))