mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
463 lines
16 KiB
Swift
463 lines
16 KiB
Swift
import Foundation
|
|
import MapKit
|
|
import OSLog
|
|
import Combine
|
|
|
|
/// Manager for handling user-uploaded map data files
|
|
class MapDataManager: ObservableObject {
|
|
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
|
|
@Published private var uploadedFiles: [MapDataMetadata] = []
|
|
private var activeFeatureCollection: GeoJSONFeatureCollection?
|
|
|
|
// 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 {
|
|
|
|
// 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 and update UI on main thread
|
|
await MainActor.run {
|
|
uploadedFiles.append(metadata)
|
|
// Clear cached configuration to force reload
|
|
activeFeatureCollection = nil
|
|
}
|
|
try saveMetadata()
|
|
|
|
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"]
|
|
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 (_, overlayCount) = try await withCheckedThrowingContinuation { continuation in
|
|
Task.detached {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
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
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
/// Get overlay count from raw GeoJSON data
|
|
private func getOverlayCount(from data: Data) throws -> Int {
|
|
// Parse as raw GeoJSON FeatureCollection
|
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let features = json["features"] as? [[String: Any]] {
|
|
return features.count
|
|
}
|
|
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
|
|
func loadFeatureCollectionForFiles(_ files: [MapDataMetadata]) -> GeoJSONFeatureCollection? {
|
|
guard !files.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
var allFeatures: [GeoJSONFeature] = []
|
|
|
|
for file in files {
|
|
do {
|
|
if let featureCollection = try loadFeatureCollectionFromFile(file) {
|
|
allFeatures.append(contentsOf: featureCollection.features)
|
|
}
|
|
} catch {
|
|
Logger.services.error("📁 MapDataManager: Failed to load feature collection from \(file.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
|
continue
|
|
}
|
|
}
|
|
|
|
guard !allFeatures.isEmpty else {
|
|
return nil
|
|
}
|
|
return GeoJSONFeatureCollection(type: "FeatureCollection", features: allFeatures)
|
|
}
|
|
|
|
/// Load and combine raw GeoJSON feature collections from all active files
|
|
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
|
|
if let cached = activeFeatureCollection {
|
|
return cached
|
|
}
|
|
|
|
// Find active user files
|
|
let activeFiles = uploadedFiles.filter { $0.isActive }
|
|
|
|
guard !activeFiles.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
var allFeatures: [GeoJSONFeature] = []
|
|
|
|
// Load features from all active files
|
|
for activeFile in activeFiles {
|
|
|
|
guard let fileURL = getUserUploadedDirectory()?.appendingPathComponent(activeFile.filename) else {
|
|
Logger.services.error("📁 MapDataManager: Could not construct file URL for: \(activeFile.filename, privacy: .public)")
|
|
continue
|
|
}
|
|
|
|
// Check if file exists before trying to load it
|
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
|
Logger.services.error("📁 MapDataManager: Active file does not exist at path: \(fileURL.path, privacy: .public)")
|
|
|
|
// Remove the missing file from our metadata
|
|
if let index = uploadedFiles.firstIndex(where: { $0.filename == activeFile.filename }) {
|
|
uploadedFiles.remove(at: index)
|
|
do {
|
|
try saveMetadata()
|
|
} catch {
|
|
Logger.services.error("📁 MapDataManager: Failed to save cleaned metadata: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
do {
|
|
let data = try Data(contentsOf: fileURL)
|
|
let featureCollection = try JSONDecoder().decode(GeoJSONFeatureCollection.self, from: data)
|
|
|
|
allFeatures.append(contentsOf: featureCollection.features)
|
|
} catch {
|
|
Logger.services.error("📁 MapDataManager: Failed to load feature collection from \(activeFile.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
// Create combined feature collection
|
|
let combinedCollection = GeoJSONFeatureCollection(
|
|
type: "FeatureCollection",
|
|
features: allFeatures
|
|
)
|
|
|
|
activeFeatureCollection = combinedCollection
|
|
return combinedCollection
|
|
}
|
|
|
|
// MARK: - File Management
|
|
|
|
/// Get all uploaded files
|
|
func getUploadedFiles() -> [MapDataMetadata] {
|
|
return uploadedFiles
|
|
}
|
|
|
|
/// Toggle the active state of an uploaded file
|
|
func toggleFileActive(_ fileId: UUID) {
|
|
if let index = uploadedFiles.firstIndex(where: { $0.id == fileId }) {
|
|
uploadedFiles[index].isActive.toggle()
|
|
|
|
// Save metadata changes
|
|
do {
|
|
try saveMetadata()
|
|
// Clear cached data to force reload
|
|
activeFeatureCollection = nil
|
|
} catch {
|
|
Logger.services.error("🚨 MapDataManager: FAILED to save metadata after toggling file: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete uploaded file
|
|
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)")
|
|
throw MapDataError.fileNotFound
|
|
}
|
|
|
|
// Check if file exists before trying to delete
|
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
|
Logger.services.warning("🗑️ MapDataManager: File does not exist at path: \(fileURL.path, privacy: .public)")
|
|
}
|
|
|
|
do {
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
} catch {
|
|
Logger.services.error("🗑️ MapDataManager: Failed to remove file: \(error.localizedDescription, privacy: .public)")
|
|
throw error
|
|
}
|
|
|
|
// Update UI-related properties on main thread
|
|
await MainActor.run {
|
|
if let index = uploadedFiles.firstIndex(where: { $0.filename == metadata.filename }) {
|
|
uploadedFiles.remove(at: index)
|
|
} else {
|
|
Logger.services.warning("🗑️ MapDataManager: File not found in uploadedFiles array")
|
|
}
|
|
}
|
|
|
|
do {
|
|
try saveMetadata()
|
|
} catch {
|
|
Logger.services.error("🗑️ MapDataManager: Failed to save metadata: \(error.localizedDescription, privacy: .public)")
|
|
throw error
|
|
}
|
|
|
|
// Clear cache if this was the active file
|
|
await MainActor.run {
|
|
if activeFeatureCollection != nil {
|
|
activeFeatureCollection = nil
|
|
}
|
|
}
|
|
|
|
// Clear GeoJSON overlay manager cache
|
|
GeoJSONOverlayManager.shared.clearCache()
|
|
|
|
// Notify UI components that a file was deleted
|
|
await MainActor.run {
|
|
NotificationCenter.default.post(name: Foundation.Notification.Name.mapDataFileDeleted, object: metadata.id)
|
|
}
|
|
|
|
}
|
|
|
|
// 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."
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Names
|
|
extension Foundation.Notification.Name {
|
|
static let mapDataFileDeleted = Foundation.Notification.Name("mapDataFileDeleted")
|
|
}
|