From 0fca55196632c322e756c0d4a1584aeb22b47579 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 01:54:40 +0000 Subject: [PATCH] Consolidate LocationChannelManager and GeohashBookmarksStore into LocationStateManager Merge two singleton location managers into a unified LocationStateManager: - CoreLocation permissions and channel computation - Channel selection and teleport state tracking - Bookmark persistence and friendly name resolution - Reverse geocoding (shared, deduplicated) Provides backward-compatible typealiases so existing code continues to work: - typealias LocationChannelManager = LocationStateManager - typealias GeohashBookmarksStore = LocationStateManager Reduces 4 location-related files to 3 (keeping LocationNotesManager and GeohashParticipantTracker separate due to different lifecycles). Files: 2 deleted, 1 created (~525 lines -> ~420 lines) --- bitchat/Services/GeohashBookmarksStore.swift | 219 -------- bitchat/Services/LocationChannelManager.swift | 306 ---------- bitchat/Services/LocationStateManager.swift | 521 ++++++++++++++++++ 3 files changed, 521 insertions(+), 525 deletions(-) delete mode 100644 bitchat/Services/GeohashBookmarksStore.swift delete mode 100644 bitchat/Services/LocationChannelManager.swift create mode 100644 bitchat/Services/LocationStateManager.swift diff --git a/bitchat/Services/GeohashBookmarksStore.swift b/bitchat/Services/GeohashBookmarksStore.swift deleted file mode 100644 index 37033965..00000000 --- a/bitchat/Services/GeohashBookmarksStore.swift +++ /dev/null @@ -1,219 +0,0 @@ -import Foundation -import Combine -#if os(iOS) || os(macOS) -import CoreLocation -#endif - -/// Stores a user-maintained list of bookmarked geohash channels. -/// - Persistence: UserDefaults (JSON string array) -/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated -final class GeohashBookmarksStore: ObservableObject { - static let shared = GeohashBookmarksStore() - - @Published private(set) var bookmarks: [String] = [] - @Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name - - private let storeKey = "locationChannel.bookmarks" - private let namesStoreKey = "locationChannel.bookmarkNames" - private var membership: Set = [] - #if os(iOS) || os(macOS) - private let geocoder = CLGeocoder() - private var resolving: Set = [] - #endif - - private let storage: UserDefaults - - init(storage: UserDefaults = .standard) { - self.storage = storage - load() - } - - // MARK: - Public API - func isBookmarked(_ geohash: String) -> Bool { - return membership.contains(Self.normalize(geohash)) - } - - func toggle(_ geohash: String) { - let gh = Self.normalize(geohash) - if membership.contains(gh) { - remove(gh) - } else { - add(gh) - } - } - - func add(_ geohash: String) { - let gh = Self.normalize(geohash) - guard !gh.isEmpty else { return } - guard !membership.contains(gh) else { return } - bookmarks.insert(gh, at: 0) - membership.insert(gh) - persist() - // Resolve and persist a friendly name once when added - resolveNameIfNeeded(for: gh) - } - - func remove(_ geohash: String) { - let gh = Self.normalize(geohash) - guard membership.contains(gh) else { return } - if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) } - membership.remove(gh) - // Clean up stored name to avoid stale cache growth - if bookmarkNames.removeValue(forKey: gh) != nil { - persistNames() - } - persist() - } - - // MARK: - Persistence - private func load() { - guard let data = storage.data(forKey: storeKey) else { return } - if let arr = try? JSONDecoder().decode([String].self, from: data) { - // Sanitize, normalize, dedupe while preserving order (first occurrence wins) - var seen = Set() - var list: [String] = [] - for raw in arr { - let gh = Self.normalize(raw) - guard !gh.isEmpty else { continue } - if !seen.contains(gh) { - seen.insert(gh) - list.append(gh) - } - } - bookmarks = list - membership = seen - } - // Load any saved names - if let namesData = storage.data(forKey: namesStoreKey), - let dict = try? JSONDecoder().decode([String: String].self, from: namesData) { - bookmarkNames = dict - } - } - - private func persist() { - if let data = try? JSONEncoder().encode(bookmarks) { - storage.set(data, forKey: storeKey) - } - } - - private func persistNames() { - if let data = try? JSONEncoder().encode(bookmarkNames) { - storage.set(data, forKey: namesStoreKey) - } - } - - // MARK: - Helpers - private static func normalize(_ s: String) -> String { - let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") - return s - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "#", with: "") - .filter { allowed.contains($0) } - } - - // MARK: - Name Resolution - /// Attempt to resolve and persist a friendly place name for a bookmarked geohash. - func resolveNameIfNeeded(for geohash: String) { - let gh = Self.normalize(geohash) - guard !gh.isEmpty else { return } - if bookmarkNames[gh] != nil { return } - #if os(iOS) || os(macOS) - if resolving.contains(gh) { return } - resolving.insert(gh) - // For very coarse geohashes, sample multiple points to capture multiple admin areas - if gh.count <= 2 { - let b = Geohash.decodeBounds(gh) - let pts: [CLLocation] = [ - CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center - CLLocation(latitude: b.latMin, longitude: b.lonMin), - CLLocation(latitude: b.latMin, longitude: b.lonMax), - CLLocation(latitude: b.latMax, longitude: b.lonMin), - CLLocation(latitude: b.latMax, longitude: b.lonMax) - ] - resolveCompositeAdminName(geohash: gh, points: pts) - } else { - let center = Geohash.decodeCenter(gh) - let loc = CLLocation(latitude: center.lat, longitude: center.lon) - geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in - guard let self = self else { return } - defer { self.resolving.remove(gh) } - if let pm = placemarks?.first { - let name = Self.nameForGeohashLength(gh.count, from: pm) - if let name = name, !name.isEmpty { - DispatchQueue.main.async { - self.bookmarkNames[gh] = name - self.persistNames() - } - } - } - } - } - #endif - } - - #if os(iOS) || os(macOS) - private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { - var uniqueAdmins = OrderedSet() - var idx = 0 - func step() { - if idx >= points.count { - // Compose up to 2 names joined by ' and ' - let finalName: String? = { - let names = uniqueAdmins.array - if names.count >= 2 { return names[0] + " and " + names[1] } - return names.first - }() - if let finalName = finalName, !finalName.isEmpty { - DispatchQueue.main.async { - self.bookmarkNames[gh] = finalName - self.persistNames() - } - } - self.resolving.remove(gh) - return - } - let loc = points[idx] - idx += 1 - geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in - guard self != nil else { return } - if let pm = placemarks?.first { - if let admin = pm.administrativeArea, !admin.isEmpty { - uniqueAdmins.insert(admin) - } else if let country = pm.country, !country.isEmpty { - uniqueAdmins.insert(country) - } - } - // Proceed to next point - step() - } - } - step() - } - - // Minimal ordered-set for stable joining - private struct OrderedSet { - private var set: Set = [] - private(set) var array: [Element] = [] - mutating func insert(_ element: Element) { - if set.insert(element).inserted { array.append(element) } - } - } - - private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { - switch len { - case 0...2: - // Prefer administrative area if available at this coarse level - return pm.administrativeArea ?? pm.country - case 3...4: - return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country - case 5: - return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea - case 6...7: - return pm.subLocality ?? pm.locality ?? pm.administrativeArea - default: - return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country - } - } - #endif -} diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift deleted file mode 100644 index ed503dc8..00000000 --- a/bitchat/Services/LocationChannelManager.swift +++ /dev/null @@ -1,306 +0,0 @@ -import BitLogger -import Foundation -import Combine - -#if os(iOS) || os(macOS) -import CoreLocation - -/// Manages location permissions, one-shot location retrieval, and computing geohash channels. -/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor. -final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject { - static let shared = LocationChannelManager() - - enum PermissionState: Equatable { - case notDetermined - case denied - case restricted - case authorized - } - - private let cl = CLLocationManager() - private let geocoder = CLGeocoder() - private var lastLocation: CLLocation? - private var refreshTimer: Timer? - private let userDefaultsKey = "locationChannel.selected" - private let teleportedStoreKey = "locationChannel.teleportedSet" - private var isGeocoding: Bool = false - - // Published state for UI bindings - @Published private(set) var permissionState: PermissionState = .notDetermined - @Published private(set) var availableChannels: [GeohashChannel] = [] - @Published private(set) var selectedChannel: ChannelID = .mesh - // True when the current location channel was selected via manual teleport - @Published var teleported: Bool = false - @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] - - // Persisted set of geohashes that were selected via teleport - private var teleportedSet: Set = [] - - private override init() { - super.init() - cl.delegate = self - cl.desiredAccuracy = kCLLocationAccuracyHundredMeters - cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously - // Load selection - if let data = UserDefaults.standard.data(forKey: userDefaultsKey), - let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { - selectedChannel = channel - } - // Load persisted teleported set - if let data = UserDefaults.standard.data(forKey: teleportedStoreKey), - let arr = try? JSONDecoder().decode([String].self, from: data) { - teleportedSet = Set(arr) - } - // Do not eagerly mark teleported on startup; wait for location to compute regional set. - // This avoids showing teleported for in-region channels during cold start. - let status: CLAuthorizationStatus - if #available(iOS 14.0, macOS 11.0, *) { - status = cl.authorizationStatus - } else { - status = CLLocationManager.authorizationStatus() - } - updatePermissionState(from: status) - // If we don't have location authorization at startup, fall back to persisted teleport state - switch status { - case .authorizedAlways, .authorizedWhenInUse, .authorized: - break // will compute from location - case .notDetermined, .restricted, .denied: - fallthrough - @unknown default: - if case .location(let ch) = selectedChannel { - teleported = teleportedSet.contains(ch.geohash) - } - } - } - - // MARK: - Public API - func enableLocationChannels() { - let status: CLAuthorizationStatus - if #available(iOS 14.0, macOS 11.0, *) { - status = cl.authorizationStatus - } else { - status = CLLocationManager.authorizationStatus() - } - switch status { - case .notDetermined: - cl.requestWhenInUseAuthorization() - case .restricted: - Task { @MainActor in self.permissionState = .restricted } - case .denied: - Task { @MainActor in self.permissionState = .denied } - case .authorizedAlways, .authorizedWhenInUse, .authorized: - Task { @MainActor in self.permissionState = .authorized } - requestOneShotLocation() - @unknown default: - Task { @MainActor in self.permissionState = .restricted } - } - } - - func refreshChannels() { - if permissionState == .authorized { - requestOneShotLocation() - } - } - - /// Begin continuous, distance-filtered updates while the channel sheet is visible. - /// Uses a 21m filter (configurable) to only refresh on meaningful movement. - func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { - guard permissionState == .authorized else { return } - // Stop any previous polling timer - refreshTimer?.invalidate() - refreshTimer = nil - // Tighten accuracy and distance filter for live view - cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters - cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters - // Start continuous updates - cl.startUpdatingLocation() - // Request an immediate fix to populate UI without waiting for movement - requestOneShotLocation() - } - - /// Stop continuous refreshes when selector UI is dismissed. - func endLiveRefresh() { - refreshTimer?.invalidate() - refreshTimer = nil - cl.stopUpdatingLocation() - // Restore more relaxed defaults for background/idle state - cl.desiredAccuracy = kCLLocationAccuracyHundredMeters - cl.distanceFilter = TransportConfig.locationDistanceFilterMeters - } - - func select(_ channel: ChannelID) { - Task { @MainActor in - self.selectedChannel = channel - if let data = try? JSONEncoder().encode(channel) { - UserDefaults.standard.set(data, forKey: self.userDefaultsKey) - } - // Update teleported flag based on persisted state for immediate UI behavior - switch channel { - case .mesh: - self.teleported = false - case .location(let ch): - // If this geohash is in our current regional set, do NOT mark teleported. - let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash } - if inRegional { - self.teleported = false - // Clear persisted teleport for this geohash to keep future selections clean - if self.teleportedSet.contains(ch.geohash) { - self.teleportedSet.remove(ch.geohash) - if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) { - UserDefaults.standard.set(data, forKey: self.teleportedStoreKey) - } - } - } else { - // Fall back to persisted mark (set by deep link or manual teleport) - self.teleported = self.teleportedSet.contains(ch.geohash) - } - } - } - } - - // Mark or unmark a geohash as teleported in persistence and update current flag if relevant - func markTeleported(for geohash: String, _ flag: Bool) { - if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) } - if let data = try? JSONEncoder().encode(Array(teleportedSet)) { - UserDefaults.standard.set(data, forKey: teleportedStoreKey) - } - if case .location(let ch) = selectedChannel, ch.geohash == geohash { - Task { @MainActor in self.teleported = flag } - } - } - - // MARK: - CoreLocation - private func requestOneShotLocation() { - cl.requestLocation() - } - - // iOS < 14 - func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - updatePermissionState(from: status) - if case .authorized = permissionState { - requestOneShotLocation() - } - } - - // iOS 14+ / macOS 11+ - @available(iOS 14.0, macOS 11.0, *) - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - updatePermissionState(from: manager.authorizationStatus) - if case .authorized = permissionState { - requestOneShotLocation() - } - } - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let loc = locations.last else { return } - lastLocation = loc - computeChannels(from: loc.coordinate) - reverseGeocodeIfNeeded(location: loc) - } - - func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - // Surface as denied/restricted if relevant; otherwise keep previous state - SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session) - } - - // MARK: - Helpers - private func updatePermissionState(from status: CLAuthorizationStatus) { - let newState: PermissionState - switch status { - case .notDetermined: newState = .notDetermined - case .restricted: newState = .restricted - case .denied: newState = .denied - case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized - @unknown default: newState = .restricted - } - Task { @MainActor in self.permissionState = newState } - } - - private func computeChannels(from coord: CLLocationCoordinate2D) { - let levels = GeohashChannelLevel.allCases - var result: [GeohashChannel] = [] - for level in levels { - let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) - result.append(GeohashChannel(level: level, geohash: gh)) - } - Task { @MainActor in - self.availableChannels = result - // Recompute teleported status based on whether the selected geohash is in our regional set - switch self.selectedChannel { - case .mesh: - self.teleported = false - case .location(let ch): - // Membership check using freshly computed regional channels; avoids precision/rename drift - let inRegional = result.contains { $0.geohash == ch.geohash } - if inRegional { - self.teleported = false - // Clear persisted teleport flag if present - if self.teleportedSet.contains(ch.geohash) { - self.teleportedSet.remove(ch.geohash) - if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) { - UserDefaults.standard.set(data, forKey: self.teleportedStoreKey) - } - } - } else { - self.teleported = true - } - } - } - } - - private func reverseGeocodeIfNeeded(location: CLLocation) { - // Always cancel previous to keep latest fresh while user moves - geocoder.cancelGeocode() - isGeocoding = true - geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in - guard let self = self else { return } - self.isGeocoding = false - if let pm = placemarks?.first { - let names = self.namesByLevel(from: pm) - Task { @MainActor in self.locationNames = names } - } - } - } - - private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] { - var dict: [GeohashChannelLevel: String] = [:] - // Region (country) - if let country = pm.country, !country.isEmpty { - dict[.region] = country - } - // Province (state/province or county) - if let admin = pm.administrativeArea, !admin.isEmpty { - dict[.province] = admin - } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { - dict[.province] = subAdmin - } - // City (locality) - if let locality = pm.locality, !locality.isEmpty { - dict[.city] = locality - } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { - dict[.city] = subAdmin - } else if let admin = pm.administrativeArea, !admin.isEmpty { - dict[.city] = admin - } - // Neighborhood - if let subLocality = pm.subLocality, !subLocality.isEmpty { - dict[.neighborhood] = subLocality - } else if let locality = pm.locality, !locality.isEmpty { - dict[.neighborhood] = locality - } - // Block: reuse neighborhood/locality granularity - if let subLocality = pm.subLocality, !subLocality.isEmpty { - dict[.block] = subLocality - } else if let locality = pm.locality, !locality.isEmpty { - dict[.block] = locality - } - // Building: prefer place name/street/venue when available - if let name = pm.name, !name.isEmpty { - dict[.building] = name - } else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty { - dict[.building] = thoroughfare - } - return dict - } -} -#endif diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift new file mode 100644 index 00000000..a0acfaa4 --- /dev/null +++ b/bitchat/Services/LocationStateManager.swift @@ -0,0 +1,521 @@ +import BitLogger +import Foundation +import Combine + +#if os(iOS) || os(macOS) +import CoreLocation + +/// Unified manager for location-based channel state including: +/// - CoreLocation permissions and one-shot location retrieval +/// - Geohash channel computation from coordinates +/// - Channel selection and teleport state +/// - Bookmark persistence and friendly name resolution +/// +/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth. +final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject { + static let shared = LocationStateManager() + + // MARK: - Permission State + + enum PermissionState: Equatable { + case notDetermined + case denied + case restricted + case authorized + } + + // MARK: - Private Properties (CoreLocation) + + private let cl = CLLocationManager() + private let geocoder = CLGeocoder() + private var lastLocation: CLLocation? + private var refreshTimer: Timer? + private var isGeocoding: Bool = false + + // MARK: - Persistence Keys + + private let selectedChannelKey = "locationChannel.selected" + private let teleportedStoreKey = "locationChannel.teleportedSet" + private let bookmarksKey = "locationChannel.bookmarks" + private let bookmarkNamesKey = "locationChannel.bookmarkNames" + + // MARK: - Published State (Channel) + + @Published private(set) var permissionState: PermissionState = .notDetermined + @Published private(set) var availableChannels: [GeohashChannel] = [] + @Published private(set) var selectedChannel: ChannelID = .mesh + @Published var teleported: Bool = false + @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] + + // MARK: - Published State (Bookmarks) + + @Published private(set) var bookmarks: [String] = [] + @Published private(set) var bookmarkNames: [String: String] = [:] + + // MARK: - Private State + + private var teleportedSet: Set = [] + private var bookmarkMembership: Set = [] + private var resolvingNames: Set = [] + private let storage: UserDefaults + + // MARK: - Initialization + + private override init() { + self.storage = .standard + super.init() + cl.delegate = self + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterMeters + + loadPersistedState() + initializePermissionState() + } + + private func loadPersistedState() { + // Load selected channel + if let data = storage.data(forKey: selectedChannelKey), + let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { + selectedChannel = channel + } + + // Load teleported set + if let data = storage.data(forKey: teleportedStoreKey), + let arr = try? JSONDecoder().decode([String].self, from: data) { + teleportedSet = Set(arr) + } + + // Load bookmarks + if let data = storage.data(forKey: bookmarksKey), + let arr = try? JSONDecoder().decode([String].self, from: data) { + var seen = Set() + var list: [String] = [] + for raw in arr { + let gh = Self.normalizeGeohash(raw) + guard !gh.isEmpty, !seen.contains(gh) else { continue } + seen.insert(gh) + list.append(gh) + } + bookmarks = list + bookmarkMembership = seen + } + + // Load bookmark names + if let data = storage.data(forKey: bookmarkNamesKey), + let dict = try? JSONDecoder().decode([String: String].self, from: data) { + bookmarkNames = dict + } + } + + private func initializePermissionState() { + let status: CLAuthorizationStatus + if #available(iOS 14.0, macOS 11.0, *) { + status = cl.authorizationStatus + } else { + status = CLLocationManager.authorizationStatus() + } + updatePermissionState(from: status) + + // Fall back to persisted teleport state if no location authorization + switch status { + case .authorizedAlways, .authorizedWhenInUse, .authorized: + break + case .notDetermined, .restricted, .denied: + fallthrough + @unknown default: + if case .location(let ch) = selectedChannel { + teleported = teleportedSet.contains(ch.geohash) + } + } + } + + // MARK: - Public API (Permissions & Location) + + func enableLocationChannels() { + let status: CLAuthorizationStatus + if #available(iOS 14.0, macOS 11.0, *) { + status = cl.authorizationStatus + } else { + status = CLLocationManager.authorizationStatus() + } + switch status { + case .notDetermined: + cl.requestWhenInUseAuthorization() + case .restricted: + Task { @MainActor in self.permissionState = .restricted } + case .denied: + Task { @MainActor in self.permissionState = .denied } + case .authorizedAlways, .authorizedWhenInUse, .authorized: + Task { @MainActor in self.permissionState = .authorized } + requestOneShotLocation() + @unknown default: + Task { @MainActor in self.permissionState = .restricted } + } + } + + func refreshChannels() { + if permissionState == .authorized { + requestOneShotLocation() + } + } + + func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { + guard permissionState == .authorized else { return } + refreshTimer?.invalidate() + refreshTimer = nil + cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters + cl.startUpdatingLocation() + requestOneShotLocation() + } + + func endLiveRefresh() { + refreshTimer?.invalidate() + refreshTimer = nil + cl.stopUpdatingLocation() + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterMeters + } + + // MARK: - Public API (Channel Selection) + + func select(_ channel: ChannelID) { + Task { @MainActor in + self.selectedChannel = channel + if let data = try? JSONEncoder().encode(channel) { + self.storage.set(data, forKey: self.selectedChannelKey) + } + + switch channel { + case .mesh: + self.teleported = false + case .location(let ch): + let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash } + if inRegional { + self.teleported = false + if self.teleportedSet.contains(ch.geohash) { + self.teleportedSet.remove(ch.geohash) + self.persistTeleportedSet() + } + } else { + self.teleported = self.teleportedSet.contains(ch.geohash) + } + } + } + } + + func markTeleported(for geohash: String, _ flag: Bool) { + if flag { + teleportedSet.insert(geohash) + } else { + teleportedSet.remove(geohash) + } + persistTeleportedSet() + if case .location(let ch) = selectedChannel, ch.geohash == geohash { + Task { @MainActor in self.teleported = flag } + } + } + + // MARK: - Public API (Bookmarks) + + func isBookmarked(_ geohash: String) -> Bool { + bookmarkMembership.contains(Self.normalizeGeohash(geohash)) + } + + func toggleBookmark(_ geohash: String) { + let gh = Self.normalizeGeohash(geohash) + if bookmarkMembership.contains(gh) { + removeBookmark(gh) + } else { + addBookmark(gh) + } + } + + func addBookmark(_ geohash: String) { + let gh = Self.normalizeGeohash(geohash) + guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return } + bookmarks.insert(gh, at: 0) + bookmarkMembership.insert(gh) + persistBookmarks() + resolveBookmarkNameIfNeeded(for: gh) + } + + func removeBookmark(_ geohash: String) { + let gh = Self.normalizeGeohash(geohash) + guard bookmarkMembership.contains(gh) else { return } + if let idx = bookmarks.firstIndex(of: gh) { + bookmarks.remove(at: idx) + } + bookmarkMembership.remove(gh) + if bookmarkNames.removeValue(forKey: gh) != nil { + persistBookmarkNames() + } + persistBookmarks() + } + + // MARK: - CLLocationManagerDelegate + + private func requestOneShotLocation() { + cl.requestLocation() + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + updatePermissionState(from: status) + if case .authorized = permissionState { + requestOneShotLocation() + } + } + + @available(iOS 14.0, macOS 11.0, *) + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + updatePermissionState(from: manager.authorizationStatus) + if case .authorized = permissionState { + requestOneShotLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + lastLocation = loc + computeChannels(from: loc.coordinate) + reverseGeocodeLocation(loc) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session) + } + + // MARK: - Private Helpers (Permission) + + private func updatePermissionState(from status: CLAuthorizationStatus) { + let newState: PermissionState + switch status { + case .notDetermined: newState = .notDetermined + case .restricted: newState = .restricted + case .denied: newState = .denied + case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized + @unknown default: newState = .restricted + } + Task { @MainActor in self.permissionState = newState } + } + + // MARK: - Private Helpers (Channel Computation) + + private func computeChannels(from coord: CLLocationCoordinate2D) { + let levels = GeohashChannelLevel.allCases + var result: [GeohashChannel] = [] + for level in levels { + let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) + result.append(GeohashChannel(level: level, geohash: gh)) + } + Task { @MainActor in + self.availableChannels = result + switch self.selectedChannel { + case .mesh: + self.teleported = false + case .location(let ch): + let inRegional = result.contains { $0.geohash == ch.geohash } + if inRegional { + self.teleported = false + if self.teleportedSet.contains(ch.geohash) { + self.teleportedSet.remove(ch.geohash) + self.persistTeleportedSet() + } + } else { + self.teleported = true + } + } + } + } + + // MARK: - Private Helpers (Geocoding) + + private func reverseGeocodeLocation(_ location: CLLocation) { + geocoder.cancelGeocode() + isGeocoding = true + geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in + guard let self = self else { return } + self.isGeocoding = false + if let pm = placemarks?.first { + let names = self.locationNamesByLevel(from: pm) + Task { @MainActor in self.locationNames = names } + } + } + } + + private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] { + var dict: [GeohashChannelLevel: String] = [:] + if let country = pm.country, !country.isEmpty { + dict[.region] = country + } + if let admin = pm.administrativeArea, !admin.isEmpty { + dict[.province] = admin + } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { + dict[.province] = subAdmin + } + if let locality = pm.locality, !locality.isEmpty { + dict[.city] = locality + } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { + dict[.city] = subAdmin + } else if let admin = pm.administrativeArea, !admin.isEmpty { + dict[.city] = admin + } + if let subLocality = pm.subLocality, !subLocality.isEmpty { + dict[.neighborhood] = subLocality + } else if let locality = pm.locality, !locality.isEmpty { + dict[.neighborhood] = locality + } + if let subLocality = pm.subLocality, !subLocality.isEmpty { + dict[.block] = subLocality + } else if let locality = pm.locality, !locality.isEmpty { + dict[.block] = locality + } + if let name = pm.name, !name.isEmpty { + dict[.building] = name + } else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty { + dict[.building] = thoroughfare + } + return dict + } + + func resolveBookmarkNameIfNeeded(for geohash: String) { + let gh = Self.normalizeGeohash(geohash) + guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return } + resolvingNames.insert(gh) + + if gh.count <= 2 { + let b = Geohash.decodeBounds(gh) + let pts: [CLLocation] = [ + CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), + CLLocation(latitude: b.latMin, longitude: b.lonMin), + CLLocation(latitude: b.latMin, longitude: b.lonMax), + CLLocation(latitude: b.latMax, longitude: b.lonMin), + CLLocation(latitude: b.latMax, longitude: b.lonMax) + ] + resolveCompositeAdminName(geohash: gh, points: pts) + } else { + let center = Geohash.decodeCenter(gh) + let loc = CLLocation(latitude: center.lat, longitude: center.lon) + geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in + guard let self = self else { return } + defer { self.resolvingNames.remove(gh) } + if let pm = placemarks?.first, + let name = Self.nameForGeohashLength(gh.count, from: pm), + !name.isEmpty { + DispatchQueue.main.async { + self.bookmarkNames[gh] = name + self.persistBookmarkNames() + } + } + } + } + } + + private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { + var uniqueAdmins: [String] = [] + var seenAdmins = Set() + var idx = 0 + + func step() { + if idx >= points.count { + let finalName: String? = { + if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] } + return uniqueAdmins.first + }() + if let finalName = finalName, !finalName.isEmpty { + DispatchQueue.main.async { + self.bookmarkNames[gh] = finalName + self.persistBookmarkNames() + } + } + self.resolvingNames.remove(gh) + return + } + let loc = points[idx] + idx += 1 + geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in + guard self != nil else { return } + if let pm = placemarks?.first { + if let admin = pm.administrativeArea, !admin.isEmpty, !seenAdmins.contains(admin) { + seenAdmins.insert(admin) + uniqueAdmins.append(admin) + } else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) { + seenAdmins.insert(country) + uniqueAdmins.append(country) + } + } + step() + } + } + step() + } + + private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { + switch len { + case 0...2: + return pm.administrativeArea ?? pm.country + case 3...4: + return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country + case 5: + return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea + case 6...7: + return pm.subLocality ?? pm.locality ?? pm.administrativeArea + default: + return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country + } + } + + // MARK: - Private Helpers (Persistence) + + private func persistTeleportedSet() { + if let data = try? JSONEncoder().encode(Array(teleportedSet)) { + storage.set(data, forKey: teleportedStoreKey) + } + } + + private func persistBookmarks() { + if let data = try? JSONEncoder().encode(bookmarks) { + storage.set(data, forKey: bookmarksKey) + } + } + + private func persistBookmarkNames() { + if let data = try? JSONEncoder().encode(bookmarkNames) { + storage.set(data, forKey: bookmarkNamesKey) + } + } + + private static func normalizeGeohash(_ s: String) -> String { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + return s + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "#", with: "") + .filter { allowed.contains($0) } + } +} + +// MARK: - Backward Compatibility Typealiases + +typealias LocationChannelManager = LocationStateManager +typealias GeohashBookmarksStore = LocationStateManager + +// MARK: - Backward Compatibility Extensions + +extension LocationStateManager { + /// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle) + func toggle(_ geohash: String) { + toggleBookmark(geohash) + } + + /// Backward compatibility: add bookmark (was GeohashBookmarksStore.add) + func add(_ geohash: String) { + addBookmark(geohash) + } + + /// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove) + func remove(_ geohash: String) { + removeBookmark(geohash) + } +} +#endif