Files
location-spoofer/App/MapLocationState.swift

446 lines
16 KiB
Swift

import Combine
import CoreLocation
import Foundation
struct MapSelection: Equatable {
let coordinate: CLLocationCoordinate2D
let source: MapSelectionSource
let explicitName: String?
let revision: UInt64
static func == (lhs: MapSelection, rhs: MapSelection) -> Bool {
lhs.coordinate.isApproximatelyEqual(to: rhs.coordinate)
&& lhs.source == rhs.source
&& lhs.explicitName == rhs.explicitName
&& lhs.revision == rhs.revision
}
}
enum MapSelectionSource: Equatable {
case initial
case userPan
case mapTap
case realtime
case search
case favorite(UUID)
}
struct RealtimeLocationIntent: Equatable {
let id: UInt64
let selectionRevision: UInt64
}
struct MapCameraCommand: Equatable, Identifiable {
enum Kind: Equatable {
case focus(coordinate: CLLocationCoordinate2D, distanceMeters: CLLocationDistance)
case zoom(factor: Double)
var isZoom: Bool { if case .zoom = self { return true }; return false }
static func == (lhs: Kind, rhs: Kind) -> Bool {
switch (lhs, rhs) {
case let (.focus(lhsCoordinate, lhsDistance), .focus(rhsCoordinate, rhsDistance)):
return lhsCoordinate.isApproximatelyEqual(to: rhsCoordinate)
&& lhsDistance == rhsDistance
case let (.zoom(lhsFactor), .zoom(rhsFactor)):
return lhsFactor == rhsFactor
default:
return false
}
}
}
let id: UInt64
let kind: Kind
}
struct MapPlaceDescriptor: Equatable {
var pointOfInterest: String?
var streetAddress: String?
var road: String?
var neighborhood: String?
var district: String?
var city: String?
var province: String?
var country: String?
init(
pointOfInterest: String? = nil,
streetAddress: String? = nil,
road: String? = nil,
neighborhood: String? = nil,
district: String? = nil,
city: String? = nil,
province: String? = nil,
country: String? = nil
) {
self.pointOfInterest = pointOfInterest?.nonEmpty
self.streetAddress = streetAddress?.nonEmpty
self.road = road?.nonEmpty
self.neighborhood = neighborhood?.nonEmpty
self.district = district?.nonEmpty
self.city = city?.nonEmpty
self.province = province?.nonEmpty
self.country = country?.nonEmpty
}
func displayName(viewportMeters: CLLocationDistance) -> String? {
switch viewportMeters {
case ..<1_000:
return firstAvailable(pointOfInterest, streetAddress, road, neighborhood, district, districtCity, cityProvince, country)
case ..<5_000:
return firstAvailable(road, streetAddress, neighborhood, district, districtCity, cityProvince, country)
case ..<12_000:
return firstAvailable(neighborhood, district, districtCity, road, city, cityProvince, country)
case ..<100_000:
return firstAvailable(districtCity, city, cityProvince, province, country, neighborhoodCity)
default:
return firstAvailable(cityProvince, provinceCountry, city, province, country, districtCity)
}
}
private var districtCity: String? { joinedDistinct(district, city) }
private var neighborhoodCity: String? { joinedDistinct(neighborhood, city) }
private var cityProvince: String? { joinedDistinct(city, province) }
private var provinceCountry: String? { joinedDistinct(province, country) }
private func firstAvailable(_ values: String?...) -> String? {
values.compactMap { $0?.nonEmpty }.first
}
private func joinedDistinct(_ first: String?, _ second: String?) -> String? {
let values = [first?.nonEmpty, second?.nonEmpty].compactMap { $0 }
let unique = values.reduce(into: [String]()) { result, value in
if !result.contains(value) { result.append(value) }
}
return unique.isEmpty ? nil : unique.joined(separator: " · ")
}
}
@MainActor
final class MapLocationState: ObservableObject {
@Published private(set) var selection: MapSelection
@Published private(set) var realtimeCoordinate: CLLocationCoordinate2D?
@Published private(set) var realtimeLocation: CLLocation?
@Published private(set) var cameraCommand: MapCameraCommand?
@Published private(set) var viewportMeters: CLLocationDistance
@Published private(set) var placeDescriptor: MapPlaceDescriptor?
private var nextSelectionRevision: UInt64 = 0
private var nextCameraCommandID: UInt64 = 0
private var nextRealtimeIntentID: UInt64 = 0
private var latestRealtimeIntentID: UInt64 = 0
init(
initialCoordinate: CLLocationCoordinate2D,
initialViewportMeters: CLLocationDistance = 1_000,
initialSource: MapSelectionSource = .initial,
initialName: String? = nil
) {
viewportMeters = initialViewportMeters
selection = MapSelection(
coordinate: initialCoordinate,
source: initialSource,
explicitName: initialName?.nonEmpty,
revision: nextSelectionRevision
)
}
var displayName: String? {
selection.explicitName?.nonEmpty ?? placeDescriptor?.displayName(viewportMeters: viewportMeters)
}
@discardableResult
func selectUserMapCenter(_ coordinate: CLLocationCoordinate2D) -> UInt64 {
guard !selection.coordinate.isApproximatelyEqual(to: coordinate) else {
return selection.revision
}
return replaceSelection(coordinate: coordinate, source: .userPan, explicitName: nil, focusDistance: nil)
}
@discardableResult
func selectMapTap(_ coordinate: CLLocationCoordinate2D) -> UInt64 {
replaceSelection(coordinate: coordinate, source: .mapTap, explicitName: nil, focusDistance: viewportMeters)
}
@discardableResult
func selectSearchResult(
_ coordinate: CLLocationCoordinate2D,
name: String
) -> UInt64 {
replaceSelection(coordinate: coordinate, source: .search, explicitName: name, focusDistance: viewportMeters)
}
@discardableResult
func selectFavorite(
_ coordinate: CLLocationCoordinate2D,
id: UUID,
name: String
) -> UInt64 {
replaceSelection(coordinate: coordinate, source: .favorite(id), explicitName: name, focusDistance: viewportMeters)
}
func beginRealtimeIntent() -> RealtimeLocationIntent {
nextRealtimeIntentID &+= 1
latestRealtimeIntentID = nextRealtimeIntentID
return RealtimeLocationIntent(id: nextRealtimeIntentID, selectionRevision: selection.revision)
}
@discardableResult
func acceptRealtimeLocation(
_ coordinate: CLLocationCoordinate2D,
intent: RealtimeLocationIntent
) -> Bool {
realtimeCoordinate = coordinate
guard intent.id == latestRealtimeIntentID,
intent.selectionRevision == selection.revision else {
return false
}
_ = replaceSelection(
coordinate: coordinate,
source: .realtime,
explicitName: nil,
focusDistance: nil
)
return true
}
func updateRealtimeLocation(_ location: CLLocation?) {
guard let location else {
realtimeLocation = nil
realtimeCoordinate = nil
return
}
guard CLLocationCoordinate2DIsValid(location.coordinate), location.horizontalAccuracy >= 0 else { return }
if let current = realtimeLocation, current.timestamp > location.timestamp { return }
realtimeLocation = location
realtimeCoordinate = location.coordinate
}
func updateRealtimeCoordinate(_ coordinate: CLLocationCoordinate2D?) {
realtimeCoordinate = coordinate
if coordinate == nil { realtimeLocation = nil }
}
/// Discards a blue-point sample represented in a superseded MapKit
/// coordinate system. The next native callback repopulates the cache.
func clearRealtimeLocationForMapCoordinateSystemChange() {
realtimeLocation = nil
realtimeCoordinate = nil
}
func updateExplicitName(_ name: String, forFavoriteID favoriteID: UUID) {
guard selection.source == .favorite(favoriteID) else { return }
selection = MapSelection(
coordinate: selection.coordinate,
source: selection.source,
explicitName: name,
revision: selection.revision
)
}
func updateViewport(distanceMeters: CLLocationDistance) {
viewportMeters = max(50, distanceMeters)
}
/// Updates only the map representation of the current physical selection.
/// This preserves revision/source so an in-flight user action is not invalidated.
func reprojectSelectionForMapCoordinateSystemChange(_ coordinate: CLLocationCoordinate2D) {
guard CLLocationCoordinate2DIsValid(coordinate),
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
selection = MapSelection(
coordinate: coordinate,
source: selection.source,
explicitName: selection.explicitName,
revision: selection.revision
)
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters))
}
@discardableResult
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
placeDescriptor = descriptor
return true
}
func focusSelection(distanceMeters: CLLocationDistance = 200) {
issueCameraCommand(.focus(coordinate: selection.coordinate, distanceMeters: distanceMeters))
}
func zoom(by factor: Double) {
guard factor.isFinite, factor > 0 else { return }
issueCameraCommand(.zoom(factor: factor))
}
private func replaceSelection(
coordinate: CLLocationCoordinate2D,
source: MapSelectionSource,
explicitName: String?,
focusDistance: CLLocationDistance?
) -> UInt64 {
nextSelectionRevision &+= 1
placeDescriptor = nil
selection = MapSelection(
coordinate: coordinate,
source: source,
explicitName: explicitName?.nonEmpty,
revision: nextSelectionRevision
)
if let focusDistance {
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: focusDistance))
}
return nextSelectionRevision
}
private func issueCameraCommand(_ kind: MapCameraCommand.Kind) {
nextCameraCommandID &+= 1
cameraCommand = MapCameraCommand(id: nextCameraCommandID, kind: kind)
}
}
private extension String {
var nonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
extension CLLocationCoordinate2D {
func isApproximatelyEqual(to other: CLLocationCoordinate2D, tolerance: CLLocationDegrees = 0.000_001) -> Bool {
abs(latitude - other.latitude) < tolerance && abs(longitude - other.longitude) < tolerance
}
}
// MARK: - 持久化存储
struct LastCoordinate: Codable, Equatable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
coordinatePair.coordinate(for: mapCoordinateSystem)
}
}
enum LastCoordinateStore {
private struct StoredPosition: Codable {
let coordinatePair: CoordinatePair
let zoomMeters: CLLocationDistance
}
private static let positionKey = "lastMapPositionV1"
private static let legacyLatKey = "lastMapLat"
private static let legacyLonKey = "lastMapLon"
static func save(
mapCoordinate: CLLocationCoordinate2D,
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
save(
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
zoomMeters: zoomMeters,
defaults: defaults
)
}
static func save(
coordinatePair: CoordinatePair,
zoomMeters: CLLocationDistance,
defaults: UserDefaults = AppGroup.defaults
) {
do {
let value = StoredPosition(coordinatePair: coordinatePair, zoomMeters: max(50, zoomMeters))
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
ViewportStore.save(value.zoomMeters, defaults: defaults)
} catch {
RuntimeLogger.error("APP", "地图", "保存当前图钉失败", error: error)
}
}
static func load(defaults: UserDefaults = AppGroup.defaults) -> LastCoordinate? {
if let data = defaults.data(forKey: positionKey),
let value = try? JSONDecoder().decode(StoredPosition.self, from: data) {
return LastCoordinate(coordinatePair: value.coordinatePair, zoomMeters: value.zoomMeters)
}
let legacyLatitude = defaults.double(forKey: legacyLatKey)
let legacyLongitude = defaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else {
return nil
}
return LastCoordinate(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: defaults) ?? 1_000
)
}
static func updateZoom(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
guard let current = load(defaults: defaults) else { return }
save(coordinatePair: current.coordinatePair, zoomMeters: meters, defaults: defaults)
}
static func migrateLegacyCoordinate(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.data(forKey: positionKey) == nil else { return }
let legacyLatitude = legacyDefaults.double(forKey: legacyLatKey)
let legacyLongitude = legacyDefaults.double(forKey: legacyLonKey)
let legacy = CLLocationCoordinate2D(latitude: legacyLatitude, longitude: legacyLongitude)
guard CLLocationCoordinate2DIsValid(legacy), legacyLatitude != 0 || legacyLongitude != 0 else { return }
let value = StoredPosition(
coordinatePair: CoordinateConverter.legacyCoordinatePair(lat: legacyLatitude, lon: legacyLongitude),
zoomMeters: ViewportStore.load(defaults: legacyDefaults) ?? 1_000
)
defaults.set(try JSONEncoder().encode(value), forKey: positionKey)
}
}
enum ViewportStore {
private static let key = "mapViewportMeters"
static func save(_ meters: CLLocationDistance, defaults: UserDefaults = AppGroup.defaults) {
let value = max(50, meters)
defaults.set(value, forKey: key)
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(value)])
}
static func load(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance? {
let value = defaults.double(forKey: key)
return value > 0 ? value : nil
}
static func loadOrDefault(defaults: UserDefaults = AppGroup.defaults) -> CLLocationDistance {
if let value = load(defaults: defaults) { return value }
let fallback: CLLocationDistance = 1_000
save(fallback, defaults: defaults)
return fallback
}
}
enum CoordinateStorageMigration {
private static let versionKey = "coordinateStorageMigrationVersion"
static let currentVersion = 1
static func migrateIfNeeded(
favorites: FavoriteLocationStore,
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) throws {
guard defaults.integer(forKey: versionKey) < currentVersion else { return }
// Pre-migration map pin and viewport values lived in UserDefaults.standard;
// favorites already lived in App Group defaults.
try LastCoordinateStore.migrateLegacyCoordinate(
defaults: defaults,
legacyDefaults: legacyDefaults
)
try favorites.migrateLegacyCoordinates()
defaults.set(currentVersion, forKey: versionKey)
RuntimeLogger.info("APP", "坐标转换", "旧坐标数据迁移完成")
}
}