diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 43118390..a17f29d1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; 3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C92E29D3B0006A988B /* Color+Hex.swift */; }; - 3D3417CC2E29D3B0006A988B /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */; }; 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; 3D3417D42E2DC293006A988B /* MapDataUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataUpload.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; @@ -335,7 +334,6 @@ 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; 3D3417C92E29D3B0006A988B /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = ""; }; - 3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Gzip.swift"; sourceTree = ""; }; 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; 3D3417D32E2DC293006A988B /* MapDataUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataUpload.swift; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; @@ -1183,7 +1181,6 @@ isa = PBXGroup; children = ( 3D3417C92E29D3B0006A988B /* Color+Hex.swift */, - 3D3417CA2E29D3B0006A988B /* Data+Gzip.swift */, DD007BB12AA59B9A00F5FA12 /* CoreData */, DDFFA7462B3A7F3C004730DB /* Bundle.swift */, DDDB444529F8A96500EE2349 /* Character.swift */, @@ -1595,7 +1592,6 @@ DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */, DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, 3D3417CB2E29D3B0006A988B /* Color+Hex.swift in Sources */, - 3D3417CC2E29D3B0006A988B /* Data+Gzip.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */, DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */, diff --git a/Meshtastic/Extensions/Data+Gzip.swift b/Meshtastic/Extensions/Data+Gzip.swift deleted file mode 100644 index 580ececf..00000000 --- a/Meshtastic/Extensions/Data+Gzip.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import Compression - -extension Data { - /// Decompresses raw deflate data - func zlibDecompressed() throws -> Data { - guard self.count > 0 else { return Data() } - - // Try Foundation's zlib first - do { - let decompressedData = try (self as NSData).decompressed(using: .zlib) as Data - print("Data+Zlib: Successfully decompressed with Foundation \(count) bytes to \(decompressedData.count) bytes") - return decompressedData - } catch { - print("Data+Zlib: Foundation decompression failed: \(error), trying raw deflate...") - } - - // Fallback to Compression framework with raw deflate - let bufferSize = count * 10 - let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) - defer { destination.deallocate() } - - return try self.withUnsafeBytes { bytes in - let source = bytes.bindMemory(to: UInt8.self) - - let result = compression_decode_buffer( - destination, bufferSize, - source.baseAddress!, count, - nil, COMPRESSION_ZLIB - ) - - guard result > 0 else { - print("Data+Zlib: Raw deflate decompression also failed, result size: \(result)") - throw ZlibError.decompression - } - - print("Data+Zlib: Successfully decompressed with raw deflate \(count) bytes to \(result) bytes") - return Data(bytes: destination, count: result) - } - } -} - -enum ZlibError: Error { - case decompression - - var localizedDescription: String { - switch self { - case .decompression: - return "Failed to decompress data" - } - } -} - -enum GzipError: Error { - case decompression - - var localizedDescription: String { - switch self { - case .decompression: - return "Failed to decompress gzip data" - } - } -} \ No newline at end of file diff --git a/Meshtastic/Helpers/MapDataManager.swift b/Meshtastic/Helpers/MapDataManager.swift index 3f9f61e4..0991224b 100644 --- a/Meshtastic/Helpers/MapDataManager.swift +++ b/Meshtastic/Helpers/MapDataManager.swift @@ -115,7 +115,7 @@ class MapDataManager { } // Check file extension - let allowedExtensions = ["json", "geojson", "kml", "kmz", "gz", "zlib"] + let allowedExtensions = ["json", "geojson"] let fileExtension = url.pathExtension.lowercased() guard allowedExtensions.contains(fileExtension) else { throw MapDataError.unsupportedFormat @@ -133,15 +133,22 @@ class MapDataManager { Task.detached { do { let data = try Data(contentsOf: url) - let processedData = try self.processData(data, filename: url.lastPathComponent) - let overlayCount = try self.getOverlayCount(from: processedData) - continuation.resume(returning: (processedData, overlayCount)) + let overlayCount = try self.getOverlayCount(from: data) + continuation.resume(returning: (data, overlayCount)) } catch { continuation.resume(throwing: error) } } } + // TODO: Add proper GeoJSON schema validation here + // - Validate required properties (type, features) + // - Validate geometry types and coordinates + // - Validate feature structure + // - Consider using JSONSchema validation + // - Ensure coordinates are within valid ranges (lat: -90 to 90, lon: -180 to 180) + // - Validate that feature properties follow expected patterns + // If this is the first file uploaded, make it active by default let isFirstFile = uploadedFiles.isEmpty @@ -158,17 +165,6 @@ class MapDataManager { ) } - /// Process data (decompress if needed) - private func processData(_ data: Data, filename: String) throws -> Data { - let fileExtension = filename.components(separatedBy: ".").last?.lowercased() ?? "" - - switch fileExtension { - case "gz", "zlib": - return try data.zlibDecompressed() - default: - return data - } - } /// Get overlay count from raw GeoJSON data private func getOverlayCount(from data: Data) throws -> Int { @@ -180,6 +176,16 @@ class MapDataManager { throw MapDataError.invalidContent } + /// Load feature collection from a single file + private func loadFeatureCollectionFromFile(_ file: MapDataMetadata) throws -> GeoJSONFeatureCollection? { + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(file.filename) else { + throw MapDataError.fileNotFound + } + + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) + } + // MARK: - Configuration Loading /// Load combined feature collection from specific files @@ -248,8 +254,7 @@ class MapDataManager { do { let data = try Data(contentsOf: fileURL) - let processedData = try processData(data, filename: activeFile.filename) - let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: processedData) + let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data) allFeatures.append(contentsOf: featureCollection.features) } catch { @@ -291,7 +296,7 @@ class MapDataManager { } /// Delete uploaded file - func deleteFile(_ metadata: MapDataMetadata) throws { + func deleteFile(_ metadata: MapDataMetadata) async throws { guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else { Logger.services.error("🗑️ MapDataManager: Could not construct file URL for: \(metadata.filename, privacy: .public)") @@ -334,27 +339,6 @@ class MapDataManager { } - /// Toggle file active status - func toggleFileActive(_ metadata: MapDataMetadata) throws { - if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) { - let newActiveState = !uploadedFiles[index].isActive - - // If making this file active, deactivate all others (only one can be active) - if newActiveState { - for i in uploadedFiles.indices { - uploadedFiles[i].isActive = (i == index) - } - } else { - // Just deactivate this file - uploadedFiles[index].isActive = false - } - - try saveMetadata() - - // Clear cache to force reload - activeFeatureCollection = nil - } - } // MARK: - Metadata Persistence diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index f5a2f84c..bf1b6186 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -30,6 +30,7 @@ struct NodeMapSwiftUI: View { @State var isShowingAltitude = false @State var isEditingSettings = false @State var isMeshMap = false + @State var enabledOverlayConfigs: Set = Set() @State private var mapRegion = MKCoordinateRegion.init() @@ -40,165 +41,191 @@ struct NodeMapSwiftUI: View { private var waypoints: FetchedResults var body: some View { - var mostRecent = node.positions?.lastObject as? PositionEntity - if node.hasPositions { + mapWithNavigation + } else { + ContentUnavailableView("No Positions", systemImage: "mappin.slash") + } + } + + private var mapWithNavigation: some View { + ZStack { + MapReader { _ in + configuredMap + } + } + .navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline) + .navigationBarItems(trailing: ZStack { - MapReader { _ in - Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { - NodeMapContent(node: node) - } - .mapScope(mapScope) - .mapStyle(mapStyle) - .mapControls { - MapScaleView(scope: mapScope) - .mapControlVisibility(.visible) - if showUserLocation { - MapUserLocationButton(scope: mapScope) - .mapControlVisibility(.visible) - } - MapPitchToggle(scope: mapScope) - .mapControlVisibility(.visible) - MapCompass(scope: mapScope) - .mapControlVisibility(.visible) - } - .controlSize(.regular) - .overlay(alignment: .bottom) { - if scene != nil && isLookingAround { - LookAroundPreview(initialScene: scene) - .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 20) - } - } - .overlay(alignment: .bottom) { - if !isLookingAround && isShowingAltitude { - PositionAltitudeChart(node: node) - .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 20) - } - } - .sheet(isPresented: $isEditingSettings) { - MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) - .onChange(of: (selectedMapLayer)) { _, newMapLayer in - switch selectedMapLayer { - case .standard: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - UserDefaults.mapLayer = newMapLayer - mapStyle = MapStyle.imagery(elevation: .flat) - case .offline: - return - } - } - } - .onChange(of: node) { - isLookingAround = false - isShowingAltitude = false - mostRecent = node.positions?.lastObject as? PositionEntity - if node.positions?.count ?? 0 > 1 { - position = .automatic - } else { - position = .camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: distance, heading: 0, pitch: 0)) - } - if let mostRecent { - Task { - scene = try? await fetchScene(for: mostRecent.coordinate) - } - } - } - .onAppear { - UIApplication.shared.isIdleTimerDisabled = true - switch selectedMapLayer { - case .standard: - mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .hybrid: - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - case .satellite: - mapStyle = MapStyle.imagery(elevation: .flat) - case .offline: - mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) - } - mostRecent = node.positions?.lastObject as? PositionEntity - if node.positions?.count ?? 0 > 1 { - position = .automatic - } else { - if let mrCoord = mostRecent?.coordinate { - position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) - } - } - if self.scene == nil { - Task { - scene = try? await fetchScene(for: mostRecent!.coordinate) - } - } - } - .safeAreaInset(edge: .bottom, alignment: .trailing) { - HStack { - Button(action: { - withAnimation { - isEditingSettings = !isEditingSettings - } - }) { - Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - /// Look Around Button - if self.scene != nil { - Button(action: { - if isShowingAltitude { - isShowingAltitude = false - } - isLookingAround = !isLookingAround - }) { - Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - } - /// Altitude Button - if node.positions?.count ?? 0 > 1 { - Button(action: { - if isLookingAround { - isLookingAround = false - } - isShowingAltitude = !isShowingAltitude - }) { - Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") - .padding(.vertical, 5) - } - .tint(Color(UIColor.secondarySystemBackground)) - .foregroundColor(.accentColor) - .buttonStyle(.borderedProminent) - } - } - .controlSize(.regular) - .padding(5) - } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - } - }} - .navigationBarTitle(String((node.user?.shortName ?? "Unknown".localized) + (" \(node.positions?.count ?? 0) points")), displayMode: .inline) - .navigationBarItems(trailing: - ZStack { ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - } else { - ContentUnavailableView("No Positions", systemImage: "mappin.slash") + } + + private var configuredMap: some View { + baseMap + .overlay(alignment: .bottom) { + lookAroundView + } + .overlay(alignment: .bottom) { + altitudeView + } + .sheet(isPresented: $isEditingSettings) { + MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs) + } + .onChange(of: selectedMapLayer) { _, newMapLayer in + updateMapStyle(for: newMapLayer) + } + .onChange(of: node) { + handleNodeChange() + } + .onAppear { + handleAppear() + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + controlButtons + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + } + } + + private var baseMap: some View { + Map(position: $position, bounds: MapCameraBounds(minimumDistance: 0, maximumDistance: .infinity), scope: mapScope) { + NodeMapContent(node: node) + } + .mapScope(mapScope) + .mapStyle(mapStyle) + .mapControls { + MapScaleView(scope: mapScope) + .mapControlVisibility(.visible) + if showUserLocation { + MapUserLocationButton(scope: mapScope) + .mapControlVisibility(.visible) + } + MapPitchToggle(scope: mapScope) + .mapControlVisibility(.visible) + MapCompass(scope: mapScope) + .mapControlVisibility(.visible) + } + .controlSize(.regular) + } + + private var lookAroundView: some View { + Group { + if scene != nil && isLookingAround { + LookAroundPreview(initialScene: scene) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } + } + + private var altitudeView: some View { + Group { + if !isLookingAround && isShowingAltitude { + PositionAltitudeChart(node: node) + .frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 250 : 400) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + } + } + } + + private var controlButtons: some View { + HStack { + Button(action: { + withAnimation { + isEditingSettings = !isEditingSettings + } + }) { + Image(systemName: isEditingSettings ? "info.circle.fill" : "info.circle") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + + if scene != nil { + Button(action: { + if isShowingAltitude { + isShowingAltitude = false + } + isLookingAround = !isLookingAround + }) { + Image(systemName: isLookingAround ? "binoculars.fill" : "binoculars") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + + if node.positions?.count ?? 0 > 1 { + Button(action: { + if isLookingAround { + isLookingAround = false + } + isShowingAltitude = !isShowingAltitude + }) { + Image(systemName: isShowingAltitude ? "mountain.2.fill" : "mountain.2") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + } + } + .controlSize(.regular) + .padding(5) + } + + private func updateMapStyle(for layer: MapLayer) { + UserDefaults.mapLayer = layer + switch layer { + case .standard: + mapStyle = MapStyle.standard(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .hybrid: + mapStyle = MapStyle.hybrid(elevation: .flat, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) + case .satellite: + mapStyle = MapStyle.imagery(elevation: .flat) + case .offline: + break + } + } + + private func handleNodeChange() { + isLookingAround = false + isShowingAltitude = false + let newMostRecent = node.positions?.lastObject as? PositionEntity + if node.positions?.count ?? 0 > 1 { + position = .automatic + } else if let mrCoord = newMostRecent?.coordinate { + position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) + } + if let newMostRecent { + Task { + scene = try? await fetchScene(for: newMostRecent.coordinate) + } + } + } + + private func handleAppear() { + UIApplication.shared.isIdleTimerDisabled = true + updateMapStyle(for: selectedMapLayer) + let mostRecent = node.positions?.lastObject as? PositionEntity + if node.positions?.count ?? 0 > 1 { + position = .automatic + } else if let mrCoord = mostRecent?.coordinate { + position = .camera(MapCamera(centerCoordinate: mrCoord, distance: distance, heading: 0, pitch: 0)) + } + if scene == nil, let mrCoord = mostRecent?.coordinate { + Task { + scene = try? await fetchScene(for: mrCoord) + } } } /// Get the look around scene diff --git a/Meshtastic/Views/Settings/MapDataUpload.swift b/Meshtastic/Views/Settings/MapDataUpload.swift index c686aaaf..0eaea62c 100644 --- a/Meshtastic/Views/Settings/MapDataUpload.swift +++ b/Meshtastic/Views/Settings/MapDataUpload.swift @@ -106,11 +106,7 @@ struct MapDataUpload: View { isPresented: $isShowingFilePicker, allowedContentTypes: [ UTType.json, - UTType(filenameExtension: "geojson") ?? UTType.json, - UTType(filenameExtension: "kml") ?? UTType.xml, - UTType(filenameExtension: "kmz") ?? UTType.zip, - UTType(filenameExtension: "gz") ?? UTType.data, - UTType(filenameExtension: "zlib") ?? UTType.data + UTType(filenameExtension: "geojson") ?? UTType.json ], allowsMultipleSelection: false ) { result in @@ -183,20 +179,19 @@ struct MapDataUpload: View { } private func toggleFileActive(_ file: MapDataMetadata) { - do { - try mapDataManager.toggleFileActive(file) - } catch { - errorMessage = "Failed to toggle file: \(error.localizedDescription)" - showError = true - } + mapDataManager.toggleFileActive(file.id) } private func deleteFile(_ file: MapDataMetadata) { - do { - try mapDataManager.deleteFile(file) - } catch { - errorMessage = "Failed to delete file: \(error.localizedDescription)" - showError = true + Task { + do { + try await mapDataManager.deleteFile(file) + } catch { + await MainActor.run { + errorMessage = "Failed to delete file: \(error.localizedDescription)" + showError = true + } + } } } }