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..c8ec79d5 --- /dev/null +++ b/bitchat/Services/LocationStateManager.swift @@ -0,0 +1,528 @@ +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() + } + + /// Internal initializer for testing with custom storage + init(storage: UserDefaults) { + self.storage = storage + super.init() + loadPersistedState() + } + + 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 diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 4fefdfc6..01534307 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -2,38 +2,98 @@ import Foundation // MARK: - Message Deduplicator (shared) +/// Thread-safe deduplicator with LRU eviction and time-based expiry. +/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer). final class MessageDeduplicator { private struct Entry { - let messageID: String + let id: String let timestamp: Date } private var entries: [Entry] = [] private var head: Int = 0 - private var lookup = Set() + private var lookup: [String: Date] = [:] // id -> timestamp for O(1) lookup private let lock = NSLock() - private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes - private let maxCount = TransportConfig.messageDedupMaxCount + private let maxAge: TimeInterval + private let maxCount: Int + + /// Initialize with default config from TransportConfig + convenience init() { + self.init( + maxAge: TransportConfig.messageDedupMaxAgeSeconds, + maxCount: TransportConfig.messageDedupMaxCount + ) + } + + /// Initialize with custom config for content deduplication + init(maxAge: TimeInterval, maxCount: Int) { + self.maxAge = maxAge + self.maxCount = maxCount + } /// Check if message is duplicate and add if not - func isDuplicate(_ messageID: String) -> Bool { + func isDuplicate(_ id: String) -> Bool { lock.lock() defer { lock.unlock() } cleanupOldEntries() - if lookup.contains(messageID) { + if lookup[id] != nil { return true } - entries.append(Entry(messageID: messageID, timestamp: Date())) - lookup.insert(messageID) + let now = Date() + entries.append(Entry(id: id, timestamp: now)) + lookup[id] = now + trimIfNeeded() + return false + } + + /// Record an ID with a specific timestamp (for content key tracking) + func record(_ id: String, timestamp: Date) { + lock.lock() + defer { lock.unlock() } + + if lookup[id] == nil { + entries.append(Entry(id: id, timestamp: timestamp)) + } + lookup[id] = timestamp + trimIfNeeded() + } + + /// Add an ID without checking (for announce-back tracking) + func markProcessed(_ id: String) { + lock.lock() + defer { lock.unlock() } + + if lookup[id] == nil { + let now = Date() + entries.append(Entry(id: id, timestamp: now)) + lookup[id] = now + } + } + + /// Check if ID exists without adding + func contains(_ id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return lookup[id] != nil + } + + /// Get timestamp for an ID (for content deduplication time-window checks) + func timestampFor(_ id: String) -> Date? { + lock.lock() + defer { lock.unlock() } + return lookup[id] + } + + private func trimIfNeeded() { // Soft-cap and advance head by a chunk to avoid O(n) shifting if (entries.count - head) > maxCount { let removeCount = min(100, entries.count - head) for i in head..<(head + removeCount) { - lookup.remove(entries[i].messageID) + lookup.removeValue(forKey: entries[i].id) } head += removeCount // Periodically compact to reclaim memory @@ -42,26 +102,6 @@ final class MessageDeduplicator { head = 0 } } - - return false - } - - /// Add an ID without checking (for announce-back tracking) - func markProcessed(_ messageID: String) { - lock.lock() - defer { lock.unlock() } - - if !lookup.contains(messageID) { - entries.append(Entry(messageID: messageID, timestamp: Date())) - lookup.insert(messageID) - } - } - - /// Check if ID exists without adding - func contains(_ messageID: String) -> Bool { - lock.lock() - defer { lock.unlock() } - return lookup.contains(messageID) } /// Clear all entries @@ -89,7 +129,7 @@ final class MessageDeduplicator { private func cleanupOldEntries() { let cutoff = Date().addingTimeInterval(-maxAge) while head < entries.count, entries[head].timestamp < cutoff { - lookup.remove(entries[head].messageID) + lookup.removeValue(forKey: entries[head].id) head += 1 } if head > 0 && head > entries.count / 2 { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0e7bcd61..266562f0 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -187,40 +187,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return String(format: "h:%016llx", h) } - // Persistent recent content map (LRU) to speed near-duplicate checks - private var contentLRUMap: [String: Date] = [:] - private var contentLRUOrder: [String] = [] - private var contentLRUHead = 0 - private let contentLRUCap = TransportConfig.contentLRUCap - private func recordContentKey(_ key: String, timestamp: Date) { - if contentLRUMap[key] == nil { contentLRUOrder.append(key) } - contentLRUMap[key] = timestamp - trimContentLRUIfNeeded() - } - - private func trimContentLRUIfNeeded() { - let activeCount = contentLRUOrder.count - contentLRUHead - guard activeCount > contentLRUCap else { return } - - let overflow = activeCount - contentLRUCap - for _ in 0.. String? { - guard contentLRUHead < contentLRUOrder.count else { return nil } - let victim = contentLRUOrder[contentLRUHead] - contentLRUHead += 1 - - // Periodically compact the backing storage to avoid unbounded growth. - if contentLRUHead >= 32 && contentLRUHead * 2 >= contentLRUOrder.count { - contentLRUOrder.removeFirst(contentLRUHead) - contentLRUHead = 0 - } - return victim - } + // Content deduplication using shared MessageDeduplicator + private let contentDeduplicator = MessageDeduplicator( + maxAge: 86400, // 24 hours - content dedup is primarily count-based + maxCount: TransportConfig.contentLRUCap + ) // MARK: - Published Properties @Published var messages: [BitchatMessage] = [] @@ -1424,9 +1395,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv timelineStore.append(message, to: activeChannel) refreshVisibleMessages(from: activeChannel) - // Update content LRU for near-dup detection + // Update content deduplicator for near-dup detection let ckey = normalizedContentKey(message.content) - recordContentKey(ckey, timestamp: message.timestamp) + contentDeduplicator.record(ckey, timestamp: message.timestamp) trimMessagesIfNeeded() @@ -2454,7 +2425,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } let key = normalizedContentKey(message.content) - recordContentKey(key, timestamp: timestamp) + contentDeduplicator.record(key, timestamp: timestamp) objectWillChange.send() return message } @@ -5184,7 +5155,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv refreshVisibleMessages(from: activeChannel) // Track the content key so relayed copies of the same system-style message are ignored let contentKey = normalizedContentKey(systemMessage.content) - recordContentKey(contentKey, timestamp: systemMessage.timestamp) + contentDeduplicator.record(contentKey, timestamp: systemMessage.timestamp) trimMessagesIfNeeded() objectWillChange.send() } @@ -6120,11 +6091,11 @@ extension ChatViewModel: PublicMessagePipelineDelegate { } func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { - contentLRUMap[key] + contentDeduplicator.timestampFor(key) } func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { - recordContentKey(key, timestamp: timestamp) + contentDeduplicator.record(key, timestamp: timestamp) } func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 998e60d7..25c8967d 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -357,7 +357,7 @@ struct LocationChannelsSheet: View { isPresented = false } .padding(.vertical, 6) - .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } + .onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) } if index < entries.count - 1 { sectionDivider