diff --git a/Localizable.xcstrings b/Localizable.xcstrings index aa955d27..ee7f5824 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1381,6 +1381,40 @@ } } }, + "%lld overlays" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld sovrapposizioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldオーバーレイ" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld slojeva" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld覆盖层" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld覆蓋層" + } + } + } + }, "%lld Readings Total" : { "localizations" : { "it" : { @@ -1545,6 +1579,9 @@ } } }, + "•" : { + "shouldTranslate" : false + }, "• %@" : { "shouldTranslate" : false }, @@ -5814,40 +5851,6 @@ } } }, - "Burning Man" : { - "localizations" : { - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "ברנינג מן" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バーニングマン" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Бернинг Мен" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "火人节" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "火人節" - } - } - } - }, "Button GPIO" : { "localizations" : { "it" : { @@ -20364,6 +20367,41 @@ } } }, + "Manage custom map overlays" : { + "comment" : "Subtitle for map data management", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestisci sovrapposizioni mappa personalizzate" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタムマップオーバーレイを管理" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upravljaj prilagođenim slojevima mape" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理自定义地图覆盖层" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理自訂地圖覆蓋層" + } + } + } + }, "Managed Device" : { "localizations" : { "it" : { @@ -20462,6 +20500,40 @@ } } }, + "Map Data" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dati Mappa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マップデータ" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaci Mape" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地图数据" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "地圖資料" + } + } + } + }, "Map Options" : { "localizations" : { "de" : { @@ -23083,6 +23155,41 @@ } } }, + "No Data" : { + "comment" : "Data source label when no files are available", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun Dato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データなし" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bez Podataka" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无数据" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "無資料" + } + } + } + }, "No device connected" : { "localizations" : { "de" : { @@ -23209,6 +23316,41 @@ } } }, + "No files uploaded yet" : { + "comment" : "Empty state text when no files are uploaded", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun file caricato ancora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まだファイルがアップロードされていません" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Još uvek nema otpremljenih datoteka" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未上传文件" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未上傳檔案" + } + } + } + }, "No Interface" : { "localizations" : { "de" : { @@ -27726,6 +27868,40 @@ } } }, + "Processing file..." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elaborazione file..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル処理中..." + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrada datoteke..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在处理文件..." + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在處理檔案..." + } + } + } + }, "Project information" : { "localizations" : { "it" : { @@ -31740,6 +31916,41 @@ } } }, + "Select Map Data File" : { + "comment" : "Button text for selecting map data file", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona File Dati Mappa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マップデータファイルを選択" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Izaberi Datoteku Podataka Mape" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择地图数据文件" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇地圖資料檔案" + } + } + } + }, "Select Node" : { "localizations" : { "de" : { @@ -39875,6 +40086,213 @@ } } }, + "Upload Error" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore Caricamento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップロードエラー" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Greška Otpremanja" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传错误" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳錯誤" + } + } + } + }, + "Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB." : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica file GeoJSON per visualizzare sovrapposizioni mappa personalizzate. I file sono memorizzati localmente e possono essere fino a 10MB." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタムマップオーバーレイを表示するためにGeoJSONファイルをアップロードしてください。ファイルはローカルに保存され、最大10MBまでです。" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otpremi GeoJSON datoteke da prikažeš prilagođene slojeve mape. Datoteke se čuvaju lokalno i mogu biti do 10MB." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传GeoJSON文件以显示自定义地图覆盖层。文件本地存储,最大10MB。" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳GeoJSON檔案以顯示自訂地圖覆蓋層。檔案本機儲存,最大10MB。" + } + } + } + }, + "Upload Map Data" : { + "comment" : "Title for map data upload screen", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica Dati Mappa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マップデータをアップロード" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otpremi Podatke Mape" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传地图数据" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳地圖資料" + } + } + } + }, + "Upload map data to enable overlays" : { + "comment" : "Prompt to upload map data when none is available", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carica dati mappa per abilitare sovrapposizioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オーバーレイを有効にするにはマップデータをアップロード" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otpremi podatke mape da omogućiš slojeve" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传地图数据以启用覆盖层" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳地圖資料以啟用覆蓋層" + } + } + } + }, + "Upload Success" : { + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caricamento Riuscito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップロード成功" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uspešno Otpremanje" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传成功" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳成功" + } + } + } + }, + "Uploaded Files" : { + "comment" : "Section header for uploaded files", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "File Caricati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップロードされたファイル" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otpremljene Datoteke" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已上传文件" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "已上傳檔案" + } + } + } + }, "Uptime" : { "localizations" : { "it" : { @@ -40309,6 +40727,41 @@ } } }, + "User Uploaded" : { + "comment" : "Data source label for user uploaded files", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caricato dall'Utente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザーがアップロード" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otpremio Korisnik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用户上传" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用者上傳" + } + } + } + }, "Username" : { "localizations" : { "de" : { @@ -40401,6 +40854,41 @@ } } }, + "Using %@ data" : { + "comment" : "Shows which data source is being used", + "localizations" : { + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizzo dati %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@データを使用" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koristi %@ podatke" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用%@数据" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用%@資料" + } + } + } + }, "Utilizes the network connection on your phone to connect to MQTT." : { "localizations" : { "it" : { @@ -42325,4 +42813,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 98753d57..43118390 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -60,7 +60,8 @@ 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 */; }; - 3D3417CE2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib in Resources */ = {isa = PBXBuildFile; fileRef = 3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */; }; + 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 */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; @@ -335,7 +336,8 @@ 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 = ""; }; - 3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */ = {isa = PBXFileReference; lastKnownFileType = file; path = BurningManGeoJSONMapConfig.json.zlib; 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 = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; @@ -797,6 +799,7 @@ DD4A911C2708C57100501B7E /* Settings */ = { isa = PBXGroup; children = ( + 3D3417D32E2DC293006A988B /* MapDataUpload.swift */, DDD5BB0E2C285F92007E03CA /* Logs */, DD93800C2BA74CE3008BEC06 /* Channels */, DD61937A2863876A00E59241 /* Config */, @@ -1057,7 +1060,6 @@ DDC2E18926CE24F70042C5E4 /* Resources */ = { isa = PBXGroup; children = ( - 3D3417CD2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib */, DDB75A192A05EB67006ED576 /* alpha.png */, DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */, DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */, @@ -1110,6 +1112,7 @@ DDC2E1A526CEB32B0042C5E4 /* Helpers */ = { isa = PBXGroup; children = ( + 3D3417D12E2DC260006A988B /* MapDataManager.swift */, 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */, BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */, DDD43FE12A78C86B0083A3E9 /* Mqtt */, @@ -1377,7 +1380,6 @@ DDDE5A1329AFEAB900490C6C /* Assets.xcassets in Resources */, DDB75A1A2A05EB67006ED576 /* alpha.png in Resources */, DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */, - 3D3417CE2E29D5D6006A988B /* BurningManGeoJSONMapConfig.json.zlib in Resources */, DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */, DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */, ); @@ -1454,6 +1456,7 @@ DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */, DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */, + 3D3417D42E2DC293006A988B /* MapDataUpload.swift in Sources */, DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */, DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */, DD3D17E02C3FB67200561584 /* LocalWeatherConditions.swift in Sources */, @@ -1601,6 +1604,7 @@ 251926922C3CB52300249DF5 /* DeleteNodeButton.swift in Sources */, DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */, DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, + 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */, 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */, diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift index 78187fc3..413e06e2 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayManager.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift @@ -9,26 +9,20 @@ class GeoJSONOverlayManager { private var configuration: GeoJSONOverlayConfiguration? private var overlays: [String: [MKOverlay]] = [:] - /// Load and decompress the consolidated configuration + /// Load user-uploaded configuration only func loadConfiguration() -> GeoJSONOverlayConfiguration? { if let cached = configuration { return cached } - guard let url = Bundle.main.url(forResource: "BurningManGeoJSONMapConfig", withExtension: "json.zlib") else { - return nil + // Load user-uploaded configuration + if let userConfig = MapDataManager.shared.loadUserConfiguration() { + configuration = userConfig + return userConfig } - do { - let compressedData = try Data(contentsOf: url) - let decompressedData = try compressedData.zlibDecompressed() - let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: decompressedData) - - configuration = config - return config - } catch { - return nil - } + // No configuration available + return nil } /// Load overlays for a specific overlay ID @@ -111,4 +105,18 @@ class GeoJSONOverlayManager { overlays.removeAll() configuration = nil } + + /// Check if user-uploaded data is available + func hasUserData() -> Bool { + return MapDataManager.shared.getUploadedFiles().contains { $0.isActive } + } + + /// Get the active data source name + func getActiveDataSource() -> String { + if hasUserData() { + return NSLocalizedString("User Uploaded", comment: "Data source label for user uploaded files") + } else { + return NSLocalizedString("No Data", comment: "Data source label when no files are available") + } + } } \ No newline at end of file diff --git a/Meshtastic/Helpers/MapDataManager.swift b/Meshtastic/Helpers/MapDataManager.swift new file mode 100644 index 00000000..cdda7305 --- /dev/null +++ b/Meshtastic/Helpers/MapDataManager.swift @@ -0,0 +1,378 @@ +import Foundation +import MapKit +import OSLog + +/// Manager for handling user-uploaded map data files +class MapDataManager { + static let shared = MapDataManager() + private init() {} + + // MARK: - Constants + private let maxFileSize: Int64 = 10 * 1024 * 1024 // 10MB + private let mapDataDirectory = "MapData" + private let userUploadedDirectory = "user_uploaded" + private let metadataFileName = "upload_history.json" + + // MARK: - Properties + private var uploadedFiles: [MapDataMetadata] = [] + private var activeConfiguration: GeoJSONOverlayConfiguration? + + // MARK: - File Management + + /// Get the base URL for map data storage + private func getMapDataDirectory() -> URL? { + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.services.error("🗂️ Could not access documents directory") + return nil + } + return documentsURL.appendingPathComponent(mapDataDirectory) + } + + /// Get the URL for user uploaded files + private func getUserUploadedDirectory() -> URL? { + guard let baseURL = getMapDataDirectory() else { return nil } + return baseURL.appendingPathComponent(userUploadedDirectory) + } + + /// Get the URL for metadata file + private func getMetadataFileURL() -> URL? { + guard let baseURL = getMapDataDirectory() else { return nil } + return baseURL.appendingPathComponent(metadataFileName) + } + + /// Create necessary directories + private func createDirectoriesIfNeeded() -> Bool { + guard let userDir = getUserUploadedDirectory() else { return false } + + do { + try FileManager.default.createDirectory(at: userDir, withIntermediateDirectories: true) + return true + } catch { + Logger.services.error("🗂️ Failed to create directories: \(error.localizedDescription, privacy: .public)") + return false + } + } + + // MARK: - File Upload & Processing + + /// Process and store an uploaded file + func processUploadedFile(from sourceURL: URL) async throws -> MapDataMetadata { + Logger.services.info("📁 Processing uploaded file: \(sourceURL.lastPathComponent, privacy: .public)") + + // 1. Start accessing security-scoped resource + let isAccessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if isAccessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + // 2. Validate file + try validateFile(at: sourceURL) + + // 2. Create directories if needed + guard createDirectoriesIfNeeded() else { + throw MapDataError.directoryCreationFailed + } + + // 3. Generate destination filename + let timestamp = Date().timeIntervalSince1970 + let originalName = sourceURL.deletingPathExtension().lastPathComponent + let fileExtension = sourceURL.pathExtension + let newFilename = "\(originalName)_\(Int(timestamp)).\(fileExtension)" + + guard let destURL = getUserUploadedDirectory()?.appendingPathComponent(newFilename) else { + throw MapDataError.invalidDestination + } + + // 4. Copy file to app storage + try FileManager.default.copyItem(at: sourceURL, to: destURL) + + // 5. Process and validate content + let metadata = try await processFileContent(at: destURL, originalName: originalName) + + // 6. Save metadata + uploadedFiles.append(metadata) + try saveMetadata() + + // 7. Clear cached configuration to force reload + activeConfiguration = nil + + Logger.services.info("📁 Successfully processed file: \(newFilename, privacy: .public)") + return metadata + } + + /// Validate uploaded file + private func validateFile(at url: URL) throws { + let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + + // Check file size + guard let fileSize = fileAttributes.fileSize, fileSize <= maxFileSize else { + throw MapDataError.fileTooLarge + } + + // Check if it's a regular file + guard fileAttributes.isRegularFile == true else { + throw MapDataError.invalidFileType + } + + // Check file extension + let allowedExtensions = ["json", "geojson", "kml", "kmz", "gz", "zlib"] + let fileExtension = url.pathExtension.lowercased() + guard allowedExtensions.contains(fileExtension) else { + throw MapDataError.unsupportedFormat + } + } + + /// Process file content and extract metadata + private func processFileContent(at url: URL, originalName: String) async throws -> MapDataMetadata { + let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .creationDateKey]) + let fileSize = fileAttributes.fileSize ?? 0 + let uploadDate = fileAttributes.creationDate ?? Date() + + // Read and process file content on background queue + let (processedData, overlayCount) = try await withCheckedThrowingContinuation { continuation in + 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)) + } catch { + continuation.resume(throwing: error) + } + } + } + + // If this is the first file uploaded, make it active by default + let isFirstFile = uploadedFiles.isEmpty + + return MapDataMetadata( + filename: url.lastPathComponent, + originalName: originalName, + uploadDate: uploadDate, + fileSize: Int64(fileSize), + format: url.pathExtension.lowercased(), + license: nil, // Will be extracted from content if available + attribution: nil, // Will be extracted from content if available + overlayCount: overlayCount, + isActive: isFirstFile + ) + } + + /// 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 processed data + private func getOverlayCount(from data: Data) throws -> Int { + do { + let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: data) + return config.overlays.count + } catch { + // Try parsing as raw GeoJSON + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let features = json["features"] as? [[String: Any]] { + return features.count + } + throw MapDataError.invalidContent + } + } + + // MARK: - Configuration Loading + + /// Load user configuration (priority over bundled) + func loadUserConfiguration() -> GeoJSONOverlayConfiguration? { + if let cached = activeConfiguration { + return cached + } + + // Find active user files + let activeFiles = uploadedFiles.filter { $0.isActive } + guard let activeFile = activeFiles.first else { + return nil + } + + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else { + return nil + } + + do { + let data = try Data(contentsOf: fileURL) + let processedData = try processData(data, filename: activeFile.filename) + let config = try JSONDecoder().decode(GeoJSONOverlayConfiguration.self, from: processedData) + + activeConfiguration = config + return config + } catch { + Logger.services.error("📁 Failed to load user configuration: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + // MARK: - File Management + + /// Get all uploaded files + func getUploadedFiles() -> [MapDataMetadata] { + return uploadedFiles + } + + /// Delete uploaded file + func deleteFile(_ metadata: MapDataMetadata) throws { + guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(metadata.filename) else { + throw MapDataError.fileNotFound + } + + try FileManager.default.removeItem(at: fileURL) + + if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) { + uploadedFiles.remove(at: index) + } + + try saveMetadata() + + // Clear cache if this was the active file + if activeConfiguration != nil { + activeConfiguration = nil + } + + Logger.services.info("🗑️ Deleted file: \(metadata.filename, privacy: .public)") + } + + /// 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 + activeConfiguration = nil + } + } + + // MARK: - Metadata Persistence + + /// Load metadata from disk + func loadMetadata() { + guard let metadataURL = getMetadataFileURL(), + let data = try? Data(contentsOf: metadataURL), + let files = try? JSONDecoder().decode([MapDataMetadata].self, from: data) else { + uploadedFiles = [] + return + } + + uploadedFiles = files + } + + /// Save metadata to disk + private func saveMetadata() throws { + guard let metadataURL = getMetadataFileURL() else { + throw MapDataError.invalidDestination + } + + let data = try JSONEncoder().encode(uploadedFiles) + try data.write(to: metadataURL) + } + + // MARK: - Initialization + + /// Initialize the manager + func initialize() { + loadMetadata() + } +} + +// MARK: - Supporting Types + +/// Metadata for uploaded map data files +struct MapDataMetadata: Codable, Identifiable { + let id: UUID + let filename: String + let originalName: String + let uploadDate: Date + let fileSize: Int64 + let format: String + let license: String? + let attribution: String? + let overlayCount: Int + var isActive: Bool + + init(filename: String, originalName: String, uploadDate: Date, fileSize: Int64, format: String, license: String?, attribution: String?, overlayCount: Int, isActive: Bool) { + self.id = UUID() + self.filename = filename + self.originalName = originalName + self.uploadDate = uploadDate + self.fileSize = fileSize + self.format = format + self.license = license + self.attribution = attribution + self.overlayCount = overlayCount + self.isActive = isActive + } + + var fileSizeString: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB] + formatter.countStyle = .file + return formatter.string(fromByteCount: fileSize) + } + + var uploadDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: uploadDate) + } +} + +/// Errors that can occur during map data operations +enum MapDataError: Error, LocalizedError { + case fileTooLarge + case invalidFileType + case unsupportedFormat + case invalidContent + case directoryCreationFailed + case invalidDestination + case fileNotFound + case saveFailed + + var errorDescription: String? { + switch self { + case .fileTooLarge: + return "File is too large. Maximum size is 10MB." + case .invalidFileType: + return "Invalid file type. Please select a regular file." + case .unsupportedFormat: + return "Unsupported file format. Supported formats: JSON, GeoJSON, KML, KMZ, GZ, ZLIB." + case .invalidContent: + return "Invalid file content. Please check the file format." + case .directoryCreationFailed: + return "Failed to create storage directory." + case .invalidDestination: + return "Invalid destination path." + case .fileNotFound: + return "File not found." + case .saveFailed: + return "Failed to save file." + } + } +} \ No newline at end of file diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index a0552164..3c6cc130 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -18,6 +18,39 @@ gvh.MeshtasticApple.mbtiles + + CFBundleTypeName + GeoJSON Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.json + gvh.MeshtasticApple.geojson + + + + CFBundleTypeName + KML Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.xml + gvh.MeshtasticApple.kml + + + + CFBundleTypeName + KMZ Map Data + LSHandlerRank + Default + LSItemContentTypes + + public.zip-archive + gvh.MeshtasticApple.kmz + + CFBundleExecutable $(EXECUTABLE_NAME) @@ -138,6 +171,63 @@ + + UTTypeConformsTo + + public.json + + UTTypeDescription + GeoJSON Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.geojson + UTTypeTagSpecification + + public.filename-extension + + geojson + + + + + UTTypeConformsTo + + public.xml + + UTTypeDescription + KML Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.kml + UTTypeTagSpecification + + public.filename-extension + + kml + + + + + UTTypeConformsTo + + public.zip-archive + + UTTypeDescription + KMZ Map Data + UTTypeIconFiles + + UTTypeIdentifier + gvh.MeshtasticApple.kmz + UTTypeTagSpecification + + public.filename-extension + + kmz + + + com.apple.developer.carplay-communication diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 19a001e1..05bf141e 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -67,6 +67,9 @@ struct MeshtasticAppleApp: App { self.persistenceController = persistenceController // Wire up router self.appDelegate.router = appState.router + + // Initialize map data manager + MapDataManager.shared.initialize() #if DEBUG // Show tips in development try? Tips.resetDatastore() diff --git a/Meshtastic/Resources/BurningManGeoJSONMapConfig.json.zlib b/Meshtastic/Resources/BurningManGeoJSONMapConfig.json.zlib deleted file mode 100644 index bcb493e1..00000000 Binary files a/Meshtastic/Resources/BurningManGeoJSONMapConfig.json.zlib and /dev/null differ diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 04f8c2e9..8e550d20 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -29,8 +29,8 @@ struct MeshMapContent: MapContent { @AppStorage("enableMapWaypoints") private var showWaypoints = true @Binding var selectedWaypoint: WaypointEntity? - // Burning Man GeoJSON overlays - @AppStorage("burningManShowAll") private var showBurningMan = false + // Map overlays + @AppStorage("mapOverlaysEnabled") private var showMapOverlays = false @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn) var positions: FetchedResults @@ -232,7 +232,7 @@ struct MeshMapContent: MapContent { } /// GeoJSON Overlays (Configuration-Driven) - if showBurningMan { + if showMapOverlays { let overlayManager = GeoJSONOverlayManager.shared let availableOverlays = overlayManager.getAvailableOverlayIds() diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index 3079a9d9..765d62ee 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -116,19 +116,56 @@ struct MapSettingsForm: View { } } - Section(header: Text("Map Overlays")) { + Section(header: Text("Map Overlays")) { + let hasUserData = GeoJSONOverlayManager.shared.hasUserData() + + // Master toggle for map overlays Toggle(isOn: Binding( - get: { UserDefaults.standard.bool(forKey: "burningManShowAll") }, - set: { UserDefaults.standard.set($0, forKey: "burningManShowAll") } + get: { hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") }, + set: { UserDefaults.standard.set($0, forKey: "mapOverlaysEnabled") } )) { Label { - Text("Burning Man") + VStack(alignment: .leading) { + Text("Map Overlays") + Text(GeoJSONOverlayManager.shared.getActiveDataSource()) + .font(.caption) + .foregroundColor(.secondary) + } } icon: { - Image(systemName: "flame.fill") - .foregroundColor(.orange) + Image(systemName: "map") + .foregroundColor(hasUserData ? .accentColor : .secondary) } } .tint(.accentColor) + .disabled(!hasUserData) + + // Show data source info or upload prompt + if hasUserData && UserDefaults.standard.bool(forKey: "mapOverlaysEnabled") { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text(String(format: NSLocalizedString("Using %@ data", comment: "Shows which data source is being used"), GeoJSONOverlayManager.shared.getActiveDataSource())) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .padding(.leading, 35) + } else if !hasUserData { + NavigationLink(destination: MapDataUpload()) { + HStack { + Image(systemName: "arrow.up.doc") + .foregroundColor(.accentColor) + Text(NSLocalizedString("Upload map data to enable overlays", comment: "Prompt to upload map data when none is available")) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.leading, 35) + } } } diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift index e5ff252c..468a7ef2 100644 --- a/Meshtastic/Views/Settings/AppData.swift +++ b/Meshtastic/Views/Settings/AppData.swift @@ -25,6 +25,33 @@ struct AppData: View { GPSStatus() } Divider() + + // Map Data Section + Section(header: Text("Map Data")) { + NavigationLink(destination: MapDataUpload()) { + HStack { + Image(systemName: "map") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + + VStack(alignment: .leading) { + Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen")) + .font(.headline) + Text(NSLocalizedString("Manage custom map overlays", comment: "Subtitle for map data management")) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + Divider() } List(files, id: \.self) { file in diff --git a/Meshtastic/Views/Settings/MapDataUpload.swift b/Meshtastic/Views/Settings/MapDataUpload.swift new file mode 100644 index 00000000..c686aaaf --- /dev/null +++ b/Meshtastic/Views/Settings/MapDataUpload.swift @@ -0,0 +1,273 @@ +import SwiftUI +import UniformTypeIdentifiers +import OSLog + +struct MapDataUpload: View { + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + + @State private var isShowingFilePicker = false + @State private var isProcessing = false + @State private var processingProgress: Double = 0.0 + @State private var showError = false + @State private var errorMessage = "" + @State private var showSuccess = false + @State private var successMessage = "" + + private let mapDataManager = MapDataManager.shared + + var body: some View { + VStack(spacing: 20) { + // Header + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Upload Map Data", comment: "Title for map data upload screen")) + .font(.title2) + .fontWeight(.bold) + + Text("Upload GeoJSON files to display custom map overlays. Files are stored locally and can be up to 10MB.") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + + // Upload Button + Button(action: { + isShowingFilePicker = true + }) { + HStack { + Image(systemName: "doc.badge.plus") + .font(.title2) + Text(NSLocalizedString("Select Map Data File", comment: "Button text for selecting map data file")) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(isProcessing) + .padding(.horizontal) + + // Processing Indicator + if isProcessing { + VStack(spacing: 12) { + ProgressView(value: processingProgress) + .progressViewStyle(LinearProgressViewStyle()) + .padding(.horizontal) + + Text("Processing file...") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Current Files Section + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Uploaded Files", comment: "Section header for uploaded files")) + .font(.headline) + .padding(.horizontal) + + let uploadedFiles = mapDataManager.getUploadedFiles() + + if uploadedFiles.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.title) + .foregroundColor(.secondary) + Text(NSLocalizedString("No files uploaded yet", comment: "Empty state text when no files are uploaded")) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(uploadedFiles) { file in + MapDataFileRow(file: file) { + toggleFileActive(file) + } onDelete: { + deleteFile(file) + } + } + } + .padding(.horizontal) + } + } + } + + Spacer() + } + .navigationTitle("Map Data") + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + 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 + ], + allowsMultipleSelection: false + ) { result in + handleFileSelection(result) + } + .alert("Upload Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + .alert("Upload Success", isPresented: $showSuccess) { + Button("OK") { } + } message: { + Text(successMessage) + } + .onAppear { + // Initialize map data manager if needed + mapDataManager.initialize() + } + } + + // MARK: - File Handling + + private func handleFileSelection(_ result: Result<[URL], Error>) { + do { + guard let selectedFile = try result.get().first else { return } + + // Start processing + isProcessing = true + processingProgress = 0.0 + + // Process file asynchronously + Task { + do { + // Simulate progress + await simulateProgress() + + let metadata = try await mapDataManager.processUploadedFile(from: selectedFile) + + await MainActor.run { + isProcessing = false + processingProgress = 1.0 + + successMessage = "Successfully uploaded '\(metadata.originalName)' with \(metadata.overlayCount) overlays" + showSuccess = true + } + } catch { + await MainActor.run { + isProcessing = false + processingProgress = 0.0 + + errorMessage = error.localizedDescription + showError = true + } + } + } + } catch { + errorMessage = "Failed to access file: \(error.localizedDescription)" + showError = true + } + } + + private func simulateProgress() async { + for i in 1...10 { + await MainActor.run { + processingProgress = Double(i) / 10.0 + } + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + } + } + + private func toggleFileActive(_ file: MapDataMetadata) { + do { + try mapDataManager.toggleFileActive(file) + } catch { + errorMessage = "Failed to toggle file: \(error.localizedDescription)" + showError = true + } + } + + private func deleteFile(_ file: MapDataMetadata) { + do { + try mapDataManager.deleteFile(file) + } catch { + errorMessage = "Failed to delete file: \(error.localizedDescription)" + showError = true + } + } +} + +// MARK: - Supporting Views + +struct MapDataFileRow: View { + let file: MapDataMetadata + let onToggle: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(file.originalName) + .font(.headline) + .lineLimit(1) + + Spacer() + + Toggle("", isOn: Binding( + get: { file.isActive }, + set: { _ in onToggle() } + )) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + + HStack { + Text(file.format.uppercased()) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .cornerRadius(4) + + Text(file.fileSizeString) + .font(.caption) + .foregroundColor(.secondary) + + Text("•") + .font(.caption) + .foregroundColor(.secondary) + + Text("\(file.overlayCount) overlays") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text(file.uploadDateString) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Button(action: onDelete) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(BorderlessButtonStyle()) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + } +} + +#Preview { + NavigationView { + MapDataUpload() + } +} \ No newline at end of file