diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index ba70153..b39a5cd 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -79,19 +79,20 @@ struct MapHomeView: View { @State private var wifiChangeObserverToken: UUID? @State private var wifiVerificationTask: Task? @State private var wifiVerificationID: UUID? - @State private var copyConfirmed = false + @State private var copiedCoordinateSystem: CoordinateConverter.MapCoordinateSystem? @State private var spoofState: SpoofState = .idle @State private var locationOperationTask: Task? @State private var locationOperationID: UInt64 = 0 + @State private var mapCoordinateSystemRefreshTask: Task? + @State private var mapCoordinateSystemRefreshID: UInt64 = 0 + @State private var bluePointRefreshPending = false + @State private var realtimeButtonTask: Task? + @State private var favoriteSaveTask: Task? // 激活时的坐标(本地存,绕过 C 桥接层精度丢失) @State private var activeSpoofLat: Double? @State private var activeSpoofLon: Double? @State private var lastSpoofDiagnosisSystem: CoordinateConverter.MapCoordinateSystem? @State private var hasLoggedSpoofDiagnosis = false - @State private var coordinateSystemRefreshTask: Task? - @State private var coordinateSystemRefreshTaskID: UUID? - @State private var coordinateSystemRefreshAttempts = 0 - @State private var lastCoordinateSystemRefreshAt: Date? init(setup: SetupCoordinator) { self.setup = setup @@ -237,7 +238,7 @@ struct MapHomeView: View { .shadow(color: .black.opacity(0.2), radius: 6, y: 3) } } - .disabled(realtimeRequestTask != nil || realtime.isRequesting) + .disabled(realtimeButtonTask != nil || realtimeRequestTask != nil || realtime.isRequesting) } } .padding(.trailing, 16) @@ -284,19 +285,24 @@ struct MapHomeView: View { wifiVerificationTask?.cancel() wifiVerificationTask = nil wifiVerificationID = nil - resetMapCoordinateSystemRefresh() + mapCoordinateSystemRefreshTask?.cancel() + mapCoordinateSystemRefreshTask = nil + bluePointRefreshPending = false + realtimeButtonTask?.cancel() + realtimeButtonTask = nil + favoriteSaveTask?.cancel() + favoriteSaveTask = nil } .onChange(of: scenePhase) { phase in guard phase == .active else { return } - resetMapCoordinateSystemRefresh() - refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: "App回到前台") + Task { @MainActor in + await awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台") + } } .onChange(of: proxy.isRunning) { running in if runtimeMode.mode == .localWiFi, !running && spoofState == .active { spoofState = .idle actions.clear() - resetMapCoordinateSystemRefresh() - refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: "本机代理意外停止") } } .onChange(of: runtimeMode.mode) { mode in @@ -309,7 +315,6 @@ struct MapHomeView: View { } wifiVerificationTask?.cancel() wifiVerificationTask = nil - resetMapCoordinateSystemRefresh() activeSpoofLat = nil activeSpoofLon = nil if mode == .localWiFi { @@ -426,27 +431,8 @@ struct MapHomeView: View { HStack { VStack(alignment: .leading, spacing: 3) { Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1) - Text(String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)) - .font(.caption.monospaced()) - .foregroundStyle(copyConfirmed ? .green : .secondary) - .onTapGesture { - let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude) - UIPasteboard.general.string = text - RuntimeLogger.info("APP", "地图", "已复制坐标") - copyConfirmed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false } - } - .overlay(alignment: .top) { - if copyConfirmed { - Text("已复制") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(.green, in: Capsule()) - .offset(y: -24) - } - } + coordinateRow(label: "国内标准 GCJ-02", system: .gcj02) + coordinateRow(label: "国际标准 WGS-84", system: .wgs84) } Spacer() // 帮助说明按钮 @@ -471,23 +457,7 @@ struct MapHomeView: View { favorites.select(nil) return } - let snapshot = currentSelectionFavorite - RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [ - "当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName, - "输入字段": CoordinateConverter.currentMapCoordinateSystem.diagnosticName, - "持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)" - ]) - let favorite = favorites.save( - name: snapshot.name, - mapCoordinate: mapState.selection.coordinate, - mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem, - accuracy: snapshot.accuracy - ) - mapState.selectFavorite( - favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), - id: favorite.id, - name: favorite.name - ) + saveCurrentSelectionAsFavorite() } label: { Image(systemName: favorites.selectedFavoriteID != nil ? "star.fill" : "star") .font(.system(size: 18, weight: .semibold)) @@ -496,6 +466,7 @@ struct MapHomeView: View { } .buttonStyle(.plain) .foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray) + .disabled(favoriteSaveTask != nil) .accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点") } // 收藏 @@ -616,7 +587,6 @@ struct MapHomeView: View { spoofState = .active activeSpoofLat = response.latitude activeSpoofLon = response.longitude - resetMapCoordinateSystemRefresh() RuntimeLogger.info("APP", "定位", "第三方代理坐标同步成功", details: [ "坐标标准": "WGS-84", "客户端模式": "测试模式", @@ -655,7 +625,6 @@ struct MapHomeView: View { activeSpoofLon = target.longitude lastSpoofDiagnosisSystem = nil hasLoggedSpoofDiagnosis = false - resetMapCoordinateSystemRefresh() } RuntimeLogger.info("APP", "定位", "验证结果", details: [ "success": "true", @@ -695,8 +664,6 @@ struct MapHomeView: View { spoofState = .idle activeSpoofLat = nil activeSpoofLon = nil - resetMapCoordinateSystemRefresh() - refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: "第三方虚拟定位已停用") presentSuccessfulOperationTip(.deactivation) } catch { spoofState = .active @@ -713,8 +680,6 @@ struct MapHomeView: View { activeSpoofLon = nil lastSpoofDiagnosisSystem = nil hasLoggedSpoofDiagnosis = false - resetMapCoordinateSystemRefresh() - refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: "APP虚拟定位已停用") presentSuccessfulOperationTip(.deactivation) } @@ -781,6 +746,58 @@ struct MapHomeView: View { ) } + private var currentSelectionPair: CoordinatePair { + if let stored = LastCoordinateStore.load(), + stored.coordinate(for: CoordinateConverter.currentMapCoordinateSystem) + .isApproximatelyEqual(to: mapState.selection.coordinate) { + return stored.coordinatePair + } + return CoordinatePair( + mapCoordinate: mapState.selection.coordinate, + mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem + ) + } + + private func coordinateRow( + label: String, + system: CoordinateConverter.MapCoordinateSystem + ) -> some View { + let coordinate = currentSelectionPair.coordinate(for: system) + let text = String(format: "%.6f, %.6f", coordinate.latitude, coordinate.longitude) + return HStack(spacing: 6) { + Text(label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .frame(width: 112, alignment: .leading) + Text(text) + .font(.caption.monospaced()) + .foregroundStyle(copiedCoordinateSystem == system ? .green : .secondary) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture { + UIPasteboard.general.string = text + copiedCoordinateSystem = system + RuntimeLogger.info("APP", "地图", "已复制坐标", details: [ + "坐标标准": system.diagnosticName + ]) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if copiedCoordinateSystem == system { copiedCoordinateSystem = nil } + } + } + .overlay(alignment: .topTrailing) { + if copiedCoordinateSystem == system { + Text("已复制") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.green, in: Capsule()) + .offset(y: -24) + } + } + } + private var testFavorite: FavoriteLocation { currentSelectionFavorite } private func startMapRuntimeOnce() { @@ -794,19 +811,152 @@ struct MapHomeView: View { scheduleGeocode(pair: pair, revision: mapState.selection.revision) } - private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) { + @discardableResult + private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) -> Bool { // Every current selection is persisted as a complete coordinate pair at // its input boundary. Replaying the matching stored representation // avoids a second GCJ/WGS conversion and its accumulated offset. guard let stored = LastCoordinateStore.load() else { RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准切换时未找到当前选点缓存") - return + return false } mapState.reprojectSelectionForMapCoordinateSystemChange(stored.coordinate(for: change.current)) RuntimeLogger.info("APP", "坐标转换", "地图坐标标准切换后已使用缓存坐标对回显当前选点", details: [ "from": change.previous.rawValue, "to": change.current.rawValue ]) + return true + } + + private func applyRuntimeMapCoordinateSystemChange( + _ change: CoordinateConverter.MapCoordinateSystemChange, + reason: String + ) { + geocodeDebounceTask?.cancel() + reverseGeocodeTask?.cancel() + searchRequestID &+= 1 + isSearching = false + searchResults = [] + searchError = "" + realtimeRequestTask?.cancel() + realtimeRequestTask = nil + realtimeRequestContext = nil + mapState.clearRealtimeLocationForMapCoordinateSystemChange() + let pinWasReprojected = reprojectMapSelection(for: change) + if let stored = LastCoordinateStore.load() { + scheduleGeocode(pair: stored.coordinatePair, revision: mapState.selection.revision) + } + RuntimeLogger.warning("APP", "坐标转换", "地图坐标类型已变化", details: [ + "触发原因": reason, + "旧类型": change.previous.diagnosticName, + "新类型": change.current.diagnosticName, + "图钉已按新类型重设": String(pinWasReprojected), + "蓝点缓存": "已清理", + "搜索结果": "已清理", + "异步地理编码": "已重置" + ]) + } + + @discardableResult + private func refreshRuntimeMapCoordinateSystem(reason: String) async -> Bool { + let result = await CoordinateConverter.refreshRuntimeMapCoordinateSystem(reason: reason) + guard !Task.isCancelled else { return false } + switch result { + case .changed(let change): + applyRuntimeMapCoordinateSystemChange(change, reason: reason) + return true + case .unchanged: + return true + case .unavailable, .cancelled: + return false + } + } + + private func scheduleBluePointMapCoordinateSystemRefresh() { + guard mapCoordinateSystemRefreshTask == nil else { + bluePointRefreshPending = true + return + } + mapCoordinateSystemRefreshID &+= 1 + let refreshID = mapCoordinateSystemRefreshID + mapCoordinateSystemRefreshTask = Task { @MainActor in + defer { + if refreshID == mapCoordinateSystemRefreshID { + let needsAnotherRefresh = bluePointRefreshPending && !Task.isCancelled + mapCoordinateSystemRefreshTask = nil + bluePointRefreshPending = false + if needsAnotherRefresh { + scheduleBluePointMapCoordinateSystemRefresh() + } + } + } + // Coalesce the didUpdate/regionDidChange pair generated by one + // native location sample without caching the probe result. + try? await Task.sleep(nanoseconds: 350_000_000) + guard !Task.isCancelled, refreshID == mapCoordinateSystemRefreshID else { return } + var attempt = 0 + repeat { + bluePointRefreshPending = false + _ = await refreshRuntimeMapCoordinateSystem(reason: "MapKit蓝点新样本") + attempt += 1 + } while !Task.isCancelled + && refreshID == mapCoordinateSystemRefreshID + && bluePointRefreshPending + && attempt < 2 + } + } + + private func awaitCoordinatedMapCoordinateSystemRefresh(reason: String) async { + if let pendingRefresh = mapCoordinateSystemRefreshTask { + await pendingRefresh.value + return + } + mapCoordinateSystemRefreshID &+= 1 + let refreshID = mapCoordinateSystemRefreshID + let task = Task { @MainActor in + _ = await refreshRuntimeMapCoordinateSystem(reason: reason) + } + mapCoordinateSystemRefreshTask = task + await task.value + if refreshID == mapCoordinateSystemRefreshID { + mapCoordinateSystemRefreshTask = nil + } + } + + private func saveCurrentSelectionAsFavorite() { + guard favoriteSaveTask == nil else { return } + let snapshot = currentSelectionFavorite + // Preserve the pair created when this selection entered the map. If + // the runtime probe changes type, replay its other stored field instead + // of reinterpreting the old visible coordinate as the new type. + let pair = currentSelectionPair + let selectionRevision = mapState.selection.revision + favoriteSaveTask = Task { @MainActor in + defer { favoriteSaveTask = nil } + await awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏") + guard !Task.isCancelled else { + return + } + guard mapState.selection.revision == selectionRevision else { + RuntimeLogger.info("APP", "坐标转换", "取消保存收藏:检测期间当前选点已变化") + return + } + RuntimeLogger.info("APP", "坐标转换", "保存当前选点为收藏", details: [ + "当前地图标准": CoordinateConverter.currentMapCoordinateSystem.diagnosticName, + "持久化字段": "国际标准(WGS-84)+国内标准(GCJ-02)" + ]) + let favorite = favorites.save( + name: snapshot.name, + coordinatePair: pair, + accuracy: snapshot.accuracy + ) + mapState.selectFavorite( + pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), + id: favorite.id, + name: favorite.name + ) + LastCoordinateStore.save(coordinatePair: pair, zoomMeters: mapState.viewportMeters) + } } private func registerWiFiChangeObserver() { @@ -936,6 +1086,18 @@ struct MapHomeView: View { } private func requestRealtimeLocation() { + guard realtimeButtonTask == nil else { return } + realtimeButtonTask = Task { @MainActor in + defer { realtimeButtonTask = nil } + await awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位") + guard !Task.isCancelled else { + return + } + performRealtimeLocationRequest() + } + } + + private func performRealtimeLocationRequest() { let intent = mapState.beginRealtimeIntent() RuntimeLogger.info("APP", "实时定位", "用户点击实时定位", details: [ "intentID": String(intent.id), @@ -971,6 +1133,7 @@ struct MapHomeView: View { private func handleNativeRealtimeLocation(_ location: CLLocation) { mapState.updateRealtimeLocation(location) logSpoofCoordinateDiagnosisIfNeeded(location) + scheduleBluePointMapCoordinateSystemRefresh() guard let context = realtimeRequestContext else { return } @@ -1005,17 +1168,12 @@ struct MapHomeView: View { lon: longitude, mapCoordinateSystem: .wgs84 ) - let maximumTargetDistance = max(1_000, location.horizontalAccuracy * 4) let diagnosis = CoordinateConverter.diagnoseRepresentation( sample: location.coordinate, pair: targetPair, - maximumDistance: maximumTargetDistance, + maximumDistance: max(1_000, location.horizontalAccuracy * 4), minimumSeparation: max(30, location.horizontalAccuracy) ) - let nearestTargetDistance = min(diagnosis.distanceToWGS84, diagnosis.distanceToGCJ02) - if nearestTargetDistance <= maximumTargetDistance { - refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: "虚拟定位蓝点已到达目标") - } let shouldLog = !hasLoggedSpoofDiagnosis || diagnosis.inferredSystem != lastSpoofDiagnosisSystem guard shouldLog else { return } @@ -1027,52 +1185,10 @@ struct MapHomeView: View { "蓝点回调更接近": diagnosis.inferredName, "蓝点距WGS目标米": String(format: "%.1f", diagnosis.distanceToWGS84), "蓝点距GCJ目标米": String(format: "%.1f", diagnosis.distanceToGCJ02), - "蓝点已到达目标": String(nearestTargetDistance <= maximumTargetDistance), "日志策略": "每次开启首次或判定变化" ]) } - private func resetMapCoordinateSystemRefresh() { - coordinateSystemRefreshTask?.cancel() - coordinateSystemRefreshTask = nil - coordinateSystemRefreshTaskID = nil - coordinateSystemRefreshAttempts = 0 - lastCoordinateSystemRefreshAt = nil - } - - private func refreshMapCoordinateSystemAfterLocationEnvironmentChange(trigger: String) { - guard coordinateSystemRefreshTask == nil, - coordinateSystemRefreshAttempts < 3 else { return } - if let lastCoordinateSystemRefreshAt, - Date().timeIntervalSince(lastCoordinateSystemRefreshAt) < 2 { - return - } - coordinateSystemRefreshAttempts += 1 - lastCoordinateSystemRefreshAt = Date() - let attempt = coordinateSystemRefreshAttempts - let taskID = UUID() - coordinateSystemRefreshTaskID = taskID - coordinateSystemRefreshTask = Task { @MainActor in - defer { - if coordinateSystemRefreshTaskID == taskID { - coordinateSystemRefreshTask = nil - coordinateSystemRefreshTaskID = nil - } - } - let change = await CoordinateConverter.refreshMapCoordinateSystem() - guard !Task.isCancelled, coordinateSystemRefreshTaskID == taskID else { return } - if let change { - reprojectMapSelection(for: change) - } - RuntimeLogger.info("APP", "坐标转换", "运行期地图坐标标准刷新请求结束", details: [ - "触发": trigger, - "尝试": String(attempt), - "发生切换": String(change != nil), - "当前标准": CoordinateConverter.currentMapCoordinateSystem.rawValue - ]) - } - } - private func startRealtimeLocationRequest( source: String, showFailureAlert: Bool, @@ -1146,14 +1262,7 @@ struct MapHomeView: View { sourceDescription: String ) { let currentViewport = mapState.viewportMeters - let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem let sourceCoordinateSystem = source.coordinateSystem - let mapCoordinateSystemChange = source == .coreLocation - ? CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate) - : nil - if let change = mapCoordinateSystemChange { - reprojectMapSelection(for: change) - } let pair = CoordinateConverter.coordinatePair( lat: coordinate.latitude, lon: coordinate.longitude, @@ -1170,9 +1279,7 @@ struct MapHomeView: View { "intentID": String(intent.id), "intent选点revision": String(intent.selectionRevision), "当前选点revision": String(mapState.selection.revision), - "修正前地图标准": previousMapCoordinateSystem.rawValue, - "修正后地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue, - "地图标准发生修正": String(mapCoordinateSystemChange != nil), + "App已确认地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue, "accepted": String(accepted), "显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue, "持久化字段": "WGS-84+GCJ-02" diff --git a/App/MapLocationState.swift b/App/MapLocationState.swift index 043f902..514d644 100644 --- a/App/MapLocationState.swift +++ b/App/MapLocationState.swift @@ -222,6 +222,13 @@ final class MapLocationState: ObservableObject { 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( diff --git a/Shared/CoordinateConverter.swift b/Shared/CoordinateConverter.swift index 5cec59e..b47ccaa 100644 --- a/Shared/CoordinateConverter.swift +++ b/Shared/CoordinateConverter.swift @@ -85,6 +85,13 @@ enum CoordinateConverter { let current: MapCoordinateSystem } + enum RuntimeMapCoordinateSystemRefreshResult: Equatable { + case unchanged(MapCoordinateSystem) + case changed(MapCoordinateSystemChange) + case unavailable(reason: String) + case cancelled + } + /// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。 @MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02 @MainActor private static var mapCoordinateSystemCheckPending = false @@ -110,7 +117,7 @@ enum CoordinateConverter { let nextType: MapCoordinateSystem switch await fixedAnchorCoordinateSystemProbe() { case let .response(name, count): - nextType = mapCoordinateSystem(anchorName: name) + nextType = mapCoordinateSystem(forFixedAnchorFirstResultName: name) initialMapCoordinateSystemUsedFallback = false RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [ "首条名称": name, @@ -146,7 +153,7 @@ enum CoordinateConverter { return currentMapCoordinateSystem } - _ = applyDetectedMapCoordinateSystem(nextType) + currentMapCoordinateSystem = nextType RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [ "最终标准": nextType.rawValue, "使用兜底": String(initialMapCoordinateSystemUsedFallback), @@ -155,64 +162,77 @@ enum CoordinateConverter { return nextType } - /// Re-runs the same location-independent fixed-anchor probe after a - /// spoofing environment change. Runtime callers must never infer MapKit's - /// representation from the geographic region of the current location: that - /// sample may itself already be virtual. + /// Re-runs the fixed-anchor MapKit behavior probe while the map is alive. + /// Runtime failures preserve the last confirmed type: a potentially spoofed + /// Core Location sample is not authoritative for MapKit's representation. @MainActor - static func refreshMapCoordinateSystem() async -> MapCoordinateSystemChange? { + static func refreshRuntimeMapCoordinateSystem(reason: String) async -> RuntimeMapCoordinateSystemRefreshResult { guard !mapCoordinateSystemCheckPending else { - RuntimeLogger.info("APP", "坐标转换", "运行期地图坐标标准刷新已在进行中") - return nil + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测合并到进行中请求", details: [ + "触发原因": reason, + "当前标准": currentMapCoordinateSystem.rawValue + ]) + return .unchanged(currentMapCoordinateSystem) } mapCoordinateSystemCheckPending = true defer { mapCoordinateSystemCheckPending = false } - RuntimeLogger.info("APP", "坐标转换", "运行期地图坐标标准刷新开始", details: [ - "当前标准": currentMapCoordinateSystem.rawValue, - "锚点": "22.283819,114.158439" + let previous = currentMapCoordinateSystem + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测开始", details: [ + "触发原因": reason, + "检测前标准": previous.rawValue, + "锚点": "22.283819,114.158439", + "缓存": "false" ]) + switch await fixedAnchorCoordinateSystemProbe() { case let .response(name, count): - let detected = mapCoordinateSystem(anchorName: name) + guard !Task.isCancelled else { + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测结果已过期,取消写入", details: [ + "触发原因": reason, + "保留标准": previous.rawValue + ]) + return .cancelled + } + let detected = mapCoordinateSystem(forFixedAnchorFirstResultName: name) initialMapCoordinateSystemUsedFallback = false - let change = applyDetectedMapCoordinateSystem(detected) - RuntimeLogger.info("APP", "坐标转换", "运行期地图坐标标准刷新完成", details: [ + guard detected != previous else { + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测完成,标准未变化", details: [ + "触发原因": reason, + "首条名称": name, + "结果数": String(count), + "确认标准": detected.rawValue + ]) + return .unchanged(detected) + } + let change = MapCoordinateSystemChange(previous: previous, current: detected) + currentMapCoordinateSystem = detected + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测发现切换", details: [ + "触发原因": reason, "首条名称": name, "结果数": String(count), - "探测标准": detected.rawValue, - "发生切换": String(change != nil) + "from": previous.rawValue, + "to": detected.rawValue ]) - return change - case .unavailable(let reason), .timedOut(let reason): - RuntimeLogger.warning("APP", "坐标转换", "运行期地图坐标标准刷新不可用,保留当前标准", details: [ - "原因": reason, - "当前标准": currentMapCoordinateSystem.rawValue + return .changed(change) + case .unavailable(let failureReason), .timedOut(let failureReason): + RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准运行期检测失败,保留当前标准", details: [ + "触发原因": reason, + "原因": failureReason, + "保留标准": previous.rawValue ]) - return nil + return .unavailable(reason: failureReason) case .cancelled: - RuntimeLogger.info("APP", "坐标转换", "运行期地图坐标标准刷新被取消,保留当前标准", details: [ - "当前标准": currentMapCoordinateSystem.rawValue + RuntimeLogger.info("APP", "坐标转换", "地图坐标标准运行期检测已取消", details: [ + "触发原因": reason, + "保留标准": previous.rawValue ]) - return nil + return .cancelled } } - /// Applies a coordinate system that was resolved by the fixed-anchor - /// probe. Kept as a small deterministic seam for regression tests. - @MainActor - static func applyDetectedMapCoordinateSystem(_ detected: MapCoordinateSystem) -> MapCoordinateSystemChange? { - guard detected != currentMapCoordinateSystem else { return nil } - let change = MapCoordinateSystemChange( - previous: currentMapCoordinateSystem, - current: detected - ) - currentMapCoordinateSystem = detected - return change - } - - private static func mapCoordinateSystem(anchorName: String) -> MapCoordinateSystem { - anchorName == "林士街" ? .gcj02 : .wgs84 + static func mapCoordinateSystem(forFixedAnchorFirstResultName name: String) -> MapCoordinateSystem { + name == "林士街" ? .gcj02 : .wgs84 } /// A user-requested realtime sample is WGS-84 and can correct a provisional @@ -227,12 +247,14 @@ enum CoordinateConverter { return nil } let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84 - guard let change = applyDetectedMapCoordinateSystem(next) else { + guard next != currentMapCoordinateSystem else { RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [ "当前标准": currentMapCoordinateSystem.rawValue ]) return nil } + let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next) + currentMapCoordinateSystem = next RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [ "from": change.previous.rawValue, "to": change.current.rawValue diff --git a/Shared/FavoriteLocationStore.swift b/Shared/FavoriteLocationStore.swift index a62d506..008bad5 100644 --- a/Shared/FavoriteLocationStore.swift +++ b/Shared/FavoriteLocationStore.swift @@ -116,11 +116,24 @@ final class FavoriteLocationStore: ObservableObject { mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem, accuracy: Int ) -> FavoriteLocation { - let favorite = FavoriteLocation( - name: name, - coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem), - accuracy: accuracy + save( + FavoriteLocation( + name: name, + coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem), + accuracy: accuracy + ) ) + } + + /// Saves a coordinate pair whose source representation was already typed + /// before an asynchronous map-coordinate-system refresh. + @discardableResult + func save(name: String, coordinatePair: CoordinatePair, accuracy: Int) -> FavoriteLocation { + save(FavoriteLocation(name: name, coordinatePair: coordinatePair, accuracy: accuracy)) + } + + @discardableResult + private func save(_ favorite: FavoriteLocation) -> FavoriteLocation { favorites.removeAll { abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001 && abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001 diff --git a/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift index c569159..e1186db 100644 --- a/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift +++ b/Tests/PaopaoLocationSpooferTests/FavoriteLocationStoreTests.swift @@ -36,6 +36,25 @@ final class FavoriteLocationStoreTests: XCTestCase { XCTAssertNotEqual(favorite.coordinatePair.gcj02.longitude, wgs.longitude) } + func testSavingPrecomputedPairDoesNotReinterpretItAfterMapTypeRefresh() { + let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let pair = CoordinateConverter.coordinatePair( + lat: 22.296_642, + lon: 114.172_175, + mapCoordinateSystem: .wgs84 + ) + + let favorite = FavoriteLocationStore(defaults: defaults).save( + name: "香港天文台", + coordinatePair: pair, + accuracy: 25 + ) + + XCTAssertEqual(favorite.coordinatePair, pair) + } + func testLegacyFavoriteIsUpgradedAsDomesticGCJAndRewritten() throws { let suite = "FavoriteLocationStoreTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -122,41 +141,15 @@ final class FavoriteLocationStoreTests: XCTestCase { XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem) } - @MainActor - func testDetectedMapCoordinateSystemChangePreventsHongKongWGS84DoubleConversion() { - let original = CoordinateConverter.currentMapCoordinateSystem - defer { _ = CoordinateConverter.applyDetectedMapCoordinateSystem(original) } - _ = CoordinateConverter.applyDetectedMapCoordinateSystem(.gcj02) - - let hongKongObservatory = CLLocationCoordinate2D( - latitude: 22.302_344, - longitude: 114.174_566 + func testFixedAnchorResultNameUsesOneSharedMapTypeRule() { + XCTAssertEqual( + CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "林士街"), + .gcj02 ) - let stalePair = CoordinatePair( - mapCoordinate: hongKongObservatory, - mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem + XCTAssertEqual( + CoordinateConverter.mapCoordinateSystem(forFixedAnchorFirstResultName: "Connaught Road West"), + .wgs84 ) - - let change = CoordinateConverter.applyDetectedMapCoordinateSystem(.wgs84) - let pair = CoordinatePair( - mapCoordinate: hongKongObservatory, - mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem - ) - - XCTAssertEqual(change?.previous, .gcj02) - XCTAssertEqual(change?.current, .wgs84) - XCTAssertNotEqual(stalePair.wgs84.longitude, hongKongObservatory.longitude) - XCTAssertEqual(pair.wgs84.latitude, hongKongObservatory.latitude, accuracy: 0.000_000_1) - XCTAssertEqual(pair.wgs84.longitude, hongKongObservatory.longitude, accuracy: 0.000_000_1) - } - - @MainActor - func testApplyingSameDetectedMapCoordinateSystemDoesNotReportAChange() { - let original = CoordinateConverter.currentMapCoordinateSystem - defer { _ = CoordinateConverter.applyDetectedMapCoordinateSystem(original) } - _ = CoordinateConverter.applyDetectedMapCoordinateSystem(.gcj02) - - XCTAssertNil(CoordinateConverter.applyDetectedMapCoordinateSystem(.gcj02)) } func testMapConfigurationNeverRequestsRealUserLocation() { diff --git a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift index 750231f..6bd7c72 100644 --- a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift +++ b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift @@ -173,6 +173,19 @@ final class MapLocationStateTests: XCTestCase { XCTAssertEqual(state.selection.source, .search) } + func testMapCoordinateSystemChangeClearsSupersededRealtimeSampleWithoutMovingSelection() { + let state = MapLocationState(initialCoordinate: initial) + state.selectSearchResult(.init(latitude: 31.23, longitude: 121.47), name: "外滩") + let selection = state.selection + state.updateRealtimeLocation(CLLocation(latitude: 30.42, longitude: 114.25)) + + state.clearRealtimeLocationForMapCoordinateSystemChange() + + XCTAssertNil(state.realtimeLocation) + XCTAssertNil(state.realtimeCoordinate) + XCTAssertEqual(state.selection, selection) + } + func testRealtimeIntentCanImmediatelyAcceptNativeLocation() { let state = MapLocationState(initialCoordinate: initial) let nativeLocation = CLLocation(latitude: 30.42, longitude: 114.25) diff --git a/Tests/map_refactor_contract_test.sh b/Tests/map_refactor_contract_test.sh index e88098a..740973c 100755 --- a/Tests/map_refactor_contract_test.sh +++ b/Tests/map_refactor_contract_test.sh @@ -65,12 +65,19 @@ if grep -q 'logEvent("CONNECT " + host + " -> passthrough")' "$ROOT/Core/proxy.g fi grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing" grep -q 'await CoordinateConverter.resolveInitialMapCoordinateSystem()' "$CONTENT" || fail "map type must resolve before MapHomeView construction" -grep -q 'static func refreshMapCoordinateSystem() async' "$CONVERTER" || fail "runtime MapKit coordinate-system changes must reuse the fixed-anchor probe" -grep -q 'fixedAnchorCoordinateSystemProbe()' "$CONVERTER" || fail "runtime coordinate-system refresh must use the location-independent fixed anchor" -test "$(grep -c 'initialMapCoordinateSystemUsedFallback = false' "$CONVERTER")" -ge 2 || fail "a successful runtime anchor probe must disable location-region fallback correction" -grep -q 'refreshMapCoordinateSystemAfterLocationEnvironmentChange' "$MAP_HOME" || fail "MapHomeView must refresh the coordinate system after spoofing changes the location environment" -grep -q 'nearestTargetDistance' "$MAP_HOME" || fail "spoof refresh must wait until the MapKit blue point reaches the active target" -grep -q 'reprojectMapSelection(for: change)' "$MAP_HOME" || fail "coordinate-system refresh must replay the persisted coordinate pair" +grep -q 'refreshRuntimeMapCoordinateSystem(reason:' "$CONVERTER" || fail "fixed-anchor map type must support runtime refresh" +grep -q 'scheduleBluePointMapCoordinateSystemRefresh()' "$MAP_HOME" || fail "native blue-point samples must trigger runtime map-type refresh while spoofing" +! grep -A3 'private func scheduleBluePointMapCoordinateSystemRefresh' "$MAP_HOME" | grep -q 'spoofState == .active' || fail "blue-point map-type refresh must also detect the return to physical location" +grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "点击实时定位")' "$MAP_HOME" || fail "realtime button must await the coordinated map-type refresh" +grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "保存收藏")' "$MAP_HOME" || fail "favorite save must await the coordinated map-type refresh" +grep -q 'awaitCoordinatedMapCoordinateSystemRefresh(reason: "App回到前台")' "$MAP_HOME" || fail "foreground recovery must reuse the coordinated map-type refresh" +grep -q '地图坐标标准运行期检测结果已过期,取消写入' "$CONVERTER" || fail "cancelled runtime probes must not mutate the global map type" +! grep -q 'source == .coreLocation.*correctMapCoordinateSystemUsingRealtime' "$MAP_HOME" || fail "runtime Core Location samples must not infer MapKit type" +grep -q 'clearRealtimeLocationForMapCoordinateSystemChange' "$MAP_HOME" || fail "map-type changes must discard superseded blue-point samples" +grep -q '地图坐标类型已变化' "$MAP_HOME" || fail "map-type changes must emit an explicit searchable business log" +grep -q '图钉已按新类型重设' "$MAP_HOME" || fail "map-type change log must report pin reprojection" +grep -q '国内标准 GCJ-02' "$MAP_HOME" || fail "current selection panel must show GCJ-02" +grep -q '国际标准 WGS-84' "$MAP_HOME" || fail "current selection panel must show WGS-84" grep -q 'phase = .map' "$CONTENT" || fail "ContentView must explicitly gate MapHomeView construction" ! grep -q 'startTileProbe' "$MAP_HOME" || fail "MapHomeView must not start a second fixed-anchor coordinate-system probe" ! grep -q 'initializeMap()' "$MAP_HOME" || fail "MapHomeView must not replay a second map initialization from onAppear"