mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
217 lines
8.0 KiB
Swift
217 lines
8.0 KiB
Swift
import Foundation
|
|
|
|
#if os(iOS)
|
|
import CoreLocation
|
|
import Combine
|
|
|
|
/// 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 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
|
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
|
|
|
private override init() {
|
|
super.init()
|
|
cl.delegate = self
|
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
cl.distanceFilter = 1000 // 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
|
|
}
|
|
let status: CLAuthorizationStatus
|
|
if #available(iOS 14.0, *) {
|
|
status = cl.authorizationStatus
|
|
} else {
|
|
status = CLLocationManager.authorizationStatus()
|
|
}
|
|
updatePermissionState(from: status)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
func enableLocationChannels() {
|
|
let status: CLAuthorizationStatus
|
|
if #available(iOS 14.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:
|
|
Task { @MainActor in self.permissionState = .authorized }
|
|
requestOneShotLocation()
|
|
@unknown default:
|
|
Task { @MainActor in self.permissionState = .restricted }
|
|
}
|
|
}
|
|
|
|
func refreshChannels() {
|
|
if permissionState == .authorized {
|
|
requestOneShotLocation()
|
|
}
|
|
}
|
|
|
|
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
|
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
|
guard permissionState == .authorized else { return }
|
|
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
self?.requestOneShotLocation()
|
|
}
|
|
// Kick off immediately
|
|
requestOneShotLocation()
|
|
}
|
|
|
|
/// Stop periodic refreshes when selector UI is dismissed.
|
|
func endLiveRefresh() {
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = nil
|
|
cl.stopUpdatingLocation()
|
|
}
|
|
|
|
func select(_ channel: ChannelID) {
|
|
Task { @MainActor in
|
|
self.selectedChannel = channel
|
|
if let data = try? JSONEncoder().encode(channel) {
|
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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+
|
|
@available(iOS 14.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.log("LocationChannelManager: location error: \(error.localizedDescription)",
|
|
category: SecureLogger.session, level: .error)
|
|
}
|
|
|
|
// 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: 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 }
|
|
}
|
|
|
|
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] = [:]
|
|
// Country
|
|
if let country = pm.country, !country.isEmpty {
|
|
dict[.country] = country
|
|
}
|
|
// Region (state/province or county)
|
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
dict[.region] = admin
|
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
dict[.region] = 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 without exposing street level
|
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
dict[.block] = subLocality
|
|
} else if let locality = pm.locality, !locality.isEmpty {
|
|
dict[.block] = locality
|
|
}
|
|
return dict
|
|
}
|
|
}
|
|
|
|
#endif
|