From 7e0d37d76fe0d824339174129d23d5240211a015 Mon Sep 17 00:00:00 2001 From: Jacob Powers Date: Mon, 21 Jul 2025 21:42:36 +0000 Subject: [PATCH] remove some burning man references --- Localizable.xcstrings | 558 ++++++++++++++++-- Meshtastic.xcodeproj/project.pbxproj | 12 +- .../Helpers/GeoJSONOverlayManager.swift | 34 +- Meshtastic/Helpers/MapDataManager.swift | 378 ++++++++++++ Meshtastic/Info.plist | 90 +++ Meshtastic/MeshtasticApp.swift | 3 + .../BurningManGeoJSONMapConfig.json.zlib | Bin 21773 -> 0 bytes .../Map/MapContent/MeshMapContent.swift | 6 +- .../Nodes/Helpers/Map/MapSettingsForm.swift | 49 +- Meshtastic/Views/Settings/AppData.swift | 27 + Meshtastic/Views/Settings/MapDataUpload.swift | 273 +++++++++ 11 files changed, 1369 insertions(+), 61 deletions(-) create mode 100644 Meshtastic/Helpers/MapDataManager.swift delete mode 100644 Meshtastic/Resources/BurningManGeoJSONMapConfig.json.zlib create mode 100644 Meshtastic/Views/Settings/MapDataUpload.swift 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 bcb493e10b28a5e8f3bfa9db8cd330f8c7ae40f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21773 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 A6aWAK 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