From 061d0c1f47270d22a13f417b167395274551fa81 Mon Sep 17 00:00:00 2001 From: sqz Date: Thu, 1 Oct 2026 12:23:45 +0800 Subject: [PATCH] fix: restore iOS sliders and background artwork updates Complete history and lyric diagnostic request cleanup, preserve Android volume drags, and resolve Sony API compatibility and lint errors. --- .../controllerapp/ui/ControllerAppUi.kt | 23 +- .../controllerapp/ui/VolumeSliderState.kt | 24 ++ .../controllerapp/ui/VolumeSliderStateTest.kt | 33 ++ .../IOSBleFeasibility/BLETestManager.swift | 342 ++++++++++++++--- .../IOSBleFeasibility/ContentView.swift | 216 +++++++---- .../IOSBleFeasibility/FullLyricsView.swift | 12 +- .../LiveActivityArtworkStore.swift | 61 ++- .../LiveActivityManager.swift | 9 +- .../IOSBleFeasibility/Localizable.xcstrings | 2 + .../LyricDiagnosticView.swift | 12 +- .../PerformanceStabilityTests.swift | 347 +++++++++++++++++- PlayerAgentApp/build.gradle | 1 + PlayerAgentApp/src/main/AndroidManifest.xml | 3 + .../com/example/playeragent/MainActivity.kt | 8 +- .../example/playeragent/ble/BleNotifyQueue.kt | 3 +- .../ble/ControllerGattClientManager.kt | 5 +- .../playeragent/media/CurrentLyricProbe.kt | 10 +- .../playeragent/media/QrcLyricCacheManager.kt | 9 +- .../media/QrcParsedCacheIndexStore.kt | 28 +- .../media/CurrentLyricProbeTest.kt | 18 + .../media/QrcParsedCacheIndexStoreTest.kt | 28 ++ docs/ALBUM_ART_ARCHITECTURE.md | 1 + docs/CONTROL_INTERACTION_AUDIT.md | 123 +++++++ docs/IOS_ARCHITECTURE.md | 4 + 24 files changed, 1120 insertions(+), 202 deletions(-) create mode 100644 ControllerApp/src/main/java/com/example/controllerapp/ui/VolumeSliderState.kt create mode 100644 ControllerApp/src/test/java/com/example/controllerapp/ui/VolumeSliderStateTest.kt create mode 100644 PlayerAgentApp/src/test/java/com/example/playeragent/media/CurrentLyricProbeTest.kt create mode 100644 docs/CONTROL_INTERACTION_AUDIT.md diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt index 892b57d..db3e551 100644 --- a/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt @@ -825,14 +825,15 @@ private fun VolumeControl( enabled: Boolean, onVolume: (Int) -> Unit ) { - var value by remember(playback.volumeCurrent, playback.volumeMax) { - mutableFloatStateOf(playback.volumeCurrent.toFloat()) + val sliderState = remember { VolumeSliderState() } + val value = sliderState.displayedValue(playback.volumeCurrent, playback.volumeMax) + LaunchedEffect(enabled, playback.volumeMax) { + if (!enabled || playback.volumeMax <= 0) sliderState.cancel() } - var dragging by remember { mutableStateOf(false) } - LaunchedEffect(value, dragging) { - if (dragging) { + LaunchedEffect(sliderState.draggedValue, enabled, playback.volumeMax) { + if (sliderState.draggedValue != null && enabled && playback.volumeMax > 0) { delay(90L) - onVolume(value.toInt()) + onVolume(sliderState.displayedValue(playback.volumeCurrent, playback.volumeMax).toInt()) } } Row( @@ -845,12 +846,14 @@ private fun VolumeControl( valueRange = 0f..playback.volumeMax.coerceAtLeast(1).toFloat(), enabled = enabled && playback.volumeMax > 0, onValueChange = { - dragging = true - value = it + sliderState.drag(it) }, onValueChangeFinished = { - onVolume(value.toInt()) - dragging = false + if (enabled && playback.volumeMax > 0) { + sliderState.finish(playback.volumeCurrent, playback.volumeMax)?.let(onVolume) + } else { + sliderState.cancel() + } }, modifier = Modifier.weight(1f) ) diff --git a/ControllerApp/src/main/java/com/example/controllerapp/ui/VolumeSliderState.kt b/ControllerApp/src/main/java/com/example/controllerapp/ui/VolumeSliderState.kt new file mode 100644 index 0000000..ed48a86 --- /dev/null +++ b/ControllerApp/src/main/java/com/example/controllerapp/ui/VolumeSliderState.kt @@ -0,0 +1,24 @@ +package com.example.controllerapp.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Remote acknowledgements may update playback while the local edit stays authoritative. */ +internal class VolumeSliderState { + var draggedValue by mutableStateOf(null) + private set + + fun displayedValue(current: Int, maximum: Int): Float = + (draggedValue ?: current.toFloat()).coerceIn(0f, maximum.coerceAtLeast(0).toFloat()) + + fun drag(value: Float) { draggedValue = value } + + fun finish(current: Int, maximum: Int): Int? { + val finalValue = draggedValue?.let { displayedValue(current, maximum).toInt() } + cancel() + return finalValue + } + + fun cancel() { draggedValue = null } +} diff --git a/ControllerApp/src/test/java/com/example/controllerapp/ui/VolumeSliderStateTest.kt b/ControllerApp/src/test/java/com/example/controllerapp/ui/VolumeSliderStateTest.kt new file mode 100644 index 0000000..3bd6adc --- /dev/null +++ b/ControllerApp/src/test/java/com/example/controllerapp/ui/VolumeSliderStateTest.kt @@ -0,0 +1,33 @@ +package com.example.controllerapp.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VolumeSliderStateTest { + @Test + fun delayedRemoteAckDoesNotOverwriteDragOrFinalCommand() { + val state = VolumeSliderState() + assertEquals(5f, state.displayedValue(5, 15), 0.001f) + state.drag(8f) + state.drag(12f) + assertEquals(12f, state.displayedValue(8, 15), 0.001f) + assertEquals(12, state.finish(8, 15)) + assertNull(state.draggedValue) + assertEquals(12f, state.displayedValue(12, 15), 0.001f) + assertNull(state.finish(12, 15)) + } + + @Test + fun changedMaximumClampsFinalValueAndDisconnectCancelsEdit() { + val state = VolumeSliderState() + state.drag(14f) + assertEquals(7f, state.displayedValue(3, 7), 0.001f) + assertEquals(7, state.finish(3, 7)) + state.drag(9f) + state.cancel() + assertEquals(3f, state.displayedValue(3, 15), 0.001f) + assertNull(state.finish(3, 15)) + assertEquals(0f, state.displayedValue(3, 0), 0.001f) + } +} diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index b8457d4..94a26d2 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -76,6 +76,19 @@ enum CommandWriteTimeoutPolicy { } enum CommandWriteQueuePolicy { + static func canRun( + appIsActive: Bool, + isControl: Bool, + command: String, + artworkID: String?, + artworkQuality: String?, + currentArtworkID: String + ) -> Bool { + if appIsActive || isControl { return true } + return command == "ALBUM_ART_REQUEST" && artworkQuality == "preview" && + !currentArtworkID.isEmpty && artworkID == currentArtworkID + } + static func shouldDropPending( existingCommand: String, existingIsProtected: Bool, @@ -301,6 +314,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { didSet { syncDiagnosticsStore() } } @Published private(set) var lyricDiagnosticLastUpdatedAt: Date? + @Published private(set) var lyricDiagnosticRequestError: String? @Published private(set) var isPlaying = false { didSet { syncPlaybackTimelineStore() } } @@ -596,6 +610,33 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { private var mediaFieldDumpChunks: [Int: Data] = [:] private var historyPayloads: [String: HistoryPayloadAssembly] = [:] private var pendingHistoryRequests: [String: HistoryRequestKind] = [:] + private var historyRequestTimeouts: [String: DispatchWorkItem] = [:] + private var historyRequestSequences: [String: UInt64] = [:] + private var historyTransferToken = UUID() + private var lyricDiagnosticRequestToken = UUID() + private var lyricDiagnosticTimeout: DispatchWorkItem? + private var lyricDiagnosticRequestSequence: UInt64? + private var backgroundArtworkTask: BoundedBackgroundTask? + private var backgroundArtworkID = "" + #if DEBUG + var commandSenderForTesting: ((String, [String: Any]) -> Bool)? + var responseTimeoutForTesting: TimeInterval? + + func configureResponseTests(trackID: String = "fixture-track") { + currentTrackID = trackID + connectionStatus = "已连接" + appLifecycleState = "active" + } + + func receiveStatusForTesting(_ object: [String: Any]) { + parseStatus(object, type: object["type"] as? String ?? "", recordTransportActivity: false) + } + + func disconnectResponseTests() { + clearConnectionTransports(reason: "test disconnect") + connectionStatus = "未连接" + } + #endif private var pendingPlaybackStatsRanges: [String] = [] private var refreshStatsAfterHistorySync = false private var lastSyncedHistorySessionId: Int64 = 0 @@ -705,6 +746,8 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { let isControl: Bool let volumeValue: Int? let volumeReason: String? + var artworkID: String? = nil + var artworkQuality: String? = nil } private struct TrackInfoTransferPayload: Decodable, Sendable { @@ -754,10 +797,15 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { var chunks: [Int: LyricLine] = [:] } - override init() { + override convenience init() { + self.init(automaticallyStartBluetooth: true) + } + + init(automaticallyStartBluetooth: Bool) { super.init() syncAllStores() syncPreferencesStateFromStore() + guard automaticallyStartBluetooth else { return } logAppExperienceModeLoaded() log("[BLE-iOS] app log store ready") LiveActivityCommandBridge.shared.register(self, logger: { [weak self] message in @@ -1745,6 +1793,9 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { subscribeNotifyTimeoutWorkItem?.cancel() foregroundValidationTimeoutWorkItem?.cancel() foregroundInflightSettleWorkItem?.cancel() + historyRequestTimeouts.values.forEach { $0.cancel() } + lyricDiagnosticTimeout?.cancel() + backgroundArtworkTask?.end() NotificationCenter.default.removeObserver(self) } @@ -1927,8 +1978,9 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { func requestLyricDiagnostic(manual: Bool = false) { let trackID = currentTrackID - guard !trackID.isEmpty else { - log("[LyricsDiag-iOS] request skipped reason=no track") + guard !trackID.isEmpty, connectionStatus == "已连接", appLifecycleState == "active" else { + finishLyricDiagnosticRequest(error: "诊断暂不可用,请确认连接后重试") + log("[LyricsDiag-iOS] request skipped reason=not ready") return } if !manual, @@ -1940,15 +1992,53 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { if !manual { lastAutomaticLyricDiagnosticRequestAt[trackID] = Date() } + finishLyricDiagnosticRequest() lyricDiagnosticLoading = true + let token = UUID() + lyricDiagnosticRequestToken = token + let sequence = nextCommandSeq() + lyricDiagnosticRequestSequence = sequence log("[LyricsDiag-iOS] request trackId=\(trackID) manual=\(manual)") - sendCommand( + guard sendCommand( cmd: "GET_LYRIC_DIAGNOSTIC", extra: [ "trackId": trackID, "time": Int64(Date().timeIntervalSince1970 * 1_000) - ] - ) + ], + seq: sequence + ) else { + finishLyricDiagnosticRequest(error: "诊断暂不可用,请确认连接后重试") + return + } + let timeout = DispatchWorkItem { [weak self] in + guard let self, self.lyricDiagnosticRequestToken == token else { return } + self.finishLyricDiagnosticRequest(error: "诊断请求超时,请重试") + } + lyricDiagnosticTimeout = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + responseTimeout(default: 10), execute: timeout) + } + + private func responseTimeout(default seconds: TimeInterval) -> TimeInterval { + #if DEBUG + return responseTimeoutForTesting ?? seconds + #else + return seconds + #endif + } + + private func finishLyricDiagnosticRequest(error: String? = nil) { + lyricDiagnosticTimeout?.cancel() + lyricDiagnosticTimeout = nil + lyricDiagnosticRequestToken = UUID() + if let sequence = lyricDiagnosticRequestSequence { + pendingCommandWrites.removeAll { $0.seq == sequence } + } + lyricDiagnosticRequestSequence = nil + lyricDiagnosticLoading = false + lyricDiagnosticRequestError = error + if error != nil { + lastAutomaticLyricDiagnosticRequestAt.removeValue(forKey: currentTrackID) + } } func refreshNowPlayingDiagnostics() { @@ -2457,7 +2547,6 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { playbackHistoryStatus = "同步中..." return } - isPlaybackHistorySyncing = true refreshStatsAfterHistorySync = true playbackHistoryStatus = "同步播放历史..." requestPlaybackHistorySince(afterSessionId: lastSyncedHistorySessionId) @@ -2474,11 +2563,11 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { syncPlaybackHistory() return } - isLoadingMoreHistory = true - let requestId = "history-page-\(currentTimeMs())" - pendingHistoryRequests[requestId] = .page + let requestId = "history-page-\(UUID().uuidString.prefix(8))" log("[HistorySync] request page requestId=\(requestId) before=\(beforeSessionId)") - sendCommand( + sendHistoryRequest( + requestId: requestId, + kind: .page, cmd: "GET_PLAY_HISTORY_PAGE", extra: [ "requestId": requestId, @@ -2503,10 +2592,11 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { guard connectionStatus == "已连接", !pendingPlaybackStatsRanges.isEmpty else { return } let range = pendingPlaybackStatsRanges.removeFirst() - let requestId = "stats-\(range)-\(currentTimeMs())" - pendingHistoryRequests[requestId] = .stats(range) + let requestId = "stats-\(range)-\(UUID().uuidString.prefix(8))" log("[HistorySync] request stats requestId=\(requestId) range=\(range)") - sendCommand( + sendHistoryRequest( + requestId: requestId, + kind: .stats(range), cmd: "GET_PLAY_STATS", extra: [ "requestId": requestId, @@ -2516,6 +2606,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } func clearLocalPlaybackHistory() { + resetHistoryRequests(reason: "local cache cleared") PlaybackHistoryStore.shared.clear { [weak self] in DispatchQueue.main.async { guard let self else { return } @@ -2529,10 +2620,11 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } private func requestPlaybackHistorySince(afterSessionId: Int64) { - let requestId = "history-since-\(currentTimeMs())" - pendingHistoryRequests[requestId] = .since + let requestId = "history-since-\(UUID().uuidString.prefix(8))" log("[HistorySync] request since requestId=\(requestId) after=\(afterSessionId)") - sendCommand( + sendHistoryRequest( + requestId: requestId, + kind: .since, cmd: "GET_PLAY_HISTORY_SINCE", extra: [ "requestId": requestId, @@ -2542,6 +2634,70 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { ) } + private func sendHistoryRequest( + requestId: String, + kind: HistoryRequestKind, + cmd: String, + extra: [String: Any] + ) { + pendingHistoryRequests[requestId] = kind + switch kind { + case .since: isPlaybackHistorySyncing = true + case .page: isLoadingMoreHistory = true + case .stats: break + } + let sequence = nextCommandSeq() + historyRequestSequences[requestId] = sequence + guard appLifecycleState == "active", sendCommand(cmd: cmd, extra: extra, seq: sequence) else { + failHistoryRequest(requestId, reason: "请求未发送,请确认 Sony 连接") + return + } + let token = historyTransferToken + let timeout = DispatchWorkItem { [weak self] in + guard let self, self.historyTransferToken == token else { return } + self.failHistoryRequest(requestId, reason: "请求超时,请重试") + } + historyRequestTimeouts[requestId] = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + responseTimeout(default: 30), execute: timeout) + } + + private func removeHistoryRequest(_ requestId: String) -> HistoryRequestKind? { + historyRequestTimeouts.removeValue(forKey: requestId)?.cancel() + historyPayloads.removeValue(forKey: requestId) + if let sequence = historyRequestSequences.removeValue(forKey: requestId) { + pendingCommandWrites.removeAll { $0.seq == sequence } + } + return pendingHistoryRequests.removeValue(forKey: requestId) + } + + private func failHistoryRequest(_ requestId: String, reason: String) { + guard let kind = removeHistoryRequest(requestId) else { return } + playbackHistoryStatus = "同步失败:\(reason)" + log("[HistorySync] failed requestId=\(requestId) reason=\(reason)") + switch kind { + case .since: + isPlaybackHistorySyncing = false + refreshStatsAfterHistorySync = false + case .page: + isLoadingMoreHistory = false + case .stats: + requestNextPlaybackStats() + } + } + + private func resetHistoryRequests(reason: String) { + historyTransferToken = UUID() + let hadRequests = !pendingHistoryRequests.isEmpty + for requestId in Array(pendingHistoryRequests.keys) { _ = removeHistoryRequest(requestId) } + historyPayloads.removeAll() + pendingPlaybackStatsRanges.removeAll() + isPlaybackHistorySyncing = false + isLoadingMoreHistory = false + refreshStatsAfterHistorySync = false + if hadRequests { playbackHistoryStatus = "同步已取消,请重试" } + log("[HistorySync] reset reason=\(reason)") + } + private func sendUserCommand(cmd: String, extra: [String: Any] = [:]) { playbackStateRefreshWorkItem?.cancel() playbackStateRefreshWorkItem = nil @@ -2565,11 +2721,13 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { sendCommand(cmd: cmd, extra: extra, seq: seq) } - func sendCommand(cmd: String, extra: [String: Any] = [:]) { - sendCommand(cmd: cmd, extra: extra, seq: nil) + @discardableResult + func sendCommand(cmd: String, extra: [String: Any] = [:]) -> Bool { + return sendCommand(cmd: cmd, extra: extra, seq: nil) } - private func sendCommand(cmd: String, extra: [String: Any] = [:], seq providedSeq: UInt64?) { + @discardableResult + private func sendCommand(cmd: String, extra: [String: Any] = [:], seq providedSeq: UInt64?) -> Bool { let seq = providedSeq ?? nextCommandSeq() let startMs = currentTimeMs() var payload = extra @@ -2582,6 +2740,10 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } payload["seq"] = seq + #if DEBUG + if let sender = commandSenderForTesting { return sender(cmd, payload) } + #endif + let connected = sonyPeripheral?.state == .connected let characteristicReady = sonyCommandCharacteristic != nil ctrlLog( @@ -2599,7 +2761,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { "reason=unhealthy health=\(connectionHealthState)" ) log("[Command] send failed \(cmd): unhealthy \(connectionHealthState)") - return + return false } guard JSONSerialization.isValidJSONObject(payload), @@ -2607,7 +2769,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { let text = String(data: data, encoding: .utf8) else { ctrlLog("[CTRL-iOS] write skipped seq=\(seq) cmd=\(cmd) reason=encode_failed") log("[Command] encode failed \(cmd)") - return + return false } guard let sonyPeripheral, @@ -2619,7 +2781,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { if connected, sonyCommandCharacteristic == nil { performHardReconnect(reason: "command characteristic nil while connected", manual: false) } - return + return false } enqueueCommandWrite( @@ -2632,9 +2794,12 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { enqueuedMonoMs: monotonicTimeMs(), isControl: isControlCommand(cmd), volumeValue: nil, - volumeReason: nil + volumeReason: nil, + artworkID: cmd == "ALBUM_ART_REQUEST" ? extra["id"] as? String : nil, + artworkQuality: cmd == "ALBUM_ART_REQUEST" ? extra["quality"] as? String : nil ) ) + return true } private func enqueueCommandWrite(_ request: PendingCommandWrite) { @@ -2694,22 +2859,30 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } private func flushCommandWriteQueue() { - // Background execution is opportunistic. Keep queued synchronization - // work frozen until foreground, while still allowing a user initiated - // Live Activity control (which is ordered ahead of background work). - if appLifecycleState != "active", - pendingCommandWrites.first?.isControl != true { - return + let artworkID = albumArtReceiver.currentAlbumArtID + pendingCommandWrites.removeAll { + $0.cmd == "ALBUM_ART_REQUEST" && $0.artworkID != artworkID } + // A BLE wake-up may fetch the current preview for the Island. HQ, + // history, diagnostics and periodic synchronization wait for foreground. guard commandWriteInflight.isEmpty, - !pendingCommandWrites.isEmpty, + let requestIndex = pendingCommandWrites.firstIndex(where: { + CommandWriteQueuePolicy.canRun( + appIsActive: appLifecycleState == "active", + isControl: $0.isControl, + command: $0.cmd, + artworkID: $0.artworkID, + artworkQuality: $0.artworkQuality, + currentArtworkID: artworkID + ) + }), let sonyPeripheral, sonyPeripheral.state == .connected, let sonyCommandCharacteristic else { return } - let request = pendingCommandWrites.removeFirst() + let request = pendingCommandWrites.remove(at: requestIndex) let writeBeginMs = currentTimeMs() let writeBeginMonoMs = monotonicTimeMs() commandWriteInflight.append( @@ -4460,6 +4633,10 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { private func clearConnectionTransports(reason: String) { log("[BLE-Reconnect] clear characteristics reason=\(reason)") + resetHistoryRequests(reason: reason) + finishLyricDiagnosticRequest(error: lyricDiagnosticLoading ? "诊断暂不可用,请确认连接后重试" : nil) + backgroundArtworkTask?.end() + backgroundArtworkTask = nil resetClockSync(reason: reason) coreBluetoothRestoreTimeoutWorkItem?.cancel() coreBluetoothRestoreTimeoutWorkItem = nil @@ -4649,6 +4826,8 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { @objc private func appDidBecomeActive() { updateAppLifecycleState(.active, emitLog: true) + backgroundArtworkTask?.end() + backgroundArtworkTask = nil handleAppForegroundReconnectCheck() } @@ -5922,9 +6101,9 @@ extension BLETestManager: CBPeripheralDelegate { } } - private func parseStatus(_ object: [String: Any], type: String) { + private func parseStatus(_ object: [String: Any], type: String, recordTransportActivity: Bool = true) { let apply: () -> Void = { - self.markStatusNotifyReceived(type: type) + if recordTransportActivity { self.markStatusNotifyReceived(type: type) } self.observeV3StatusMetadata(object, type: type) switch type { case "link": @@ -6280,6 +6459,7 @@ extension BLETestManager: CBPeripheralDelegate { case "albumArtOffer": let id = object["id"] as? String ?? "" + self.beginBackgroundArtworkTask(id: id) self.albumArtReceiver.handleOffer(id: id) case "albumArtStart": @@ -6445,15 +6625,7 @@ extension BLETestManager: CBPeripheralDelegate { case "playHistoryError": let requestId = object["requestId"] as? String ?? "" let message = object["message"] as? String ?? "unknown" - let failedKind = self.pendingHistoryRequests.removeValue(forKey: requestId) - if case .some(.stats) = failedKind { - self.requestNextPlaybackStats() - } else { - self.isPlaybackHistorySyncing = false - } - self.isLoadingMoreHistory = false - self.playbackHistoryStatus = "同步失败:\(message)" - self.log("[HistorySync] error requestId=\(requestId) message=\(message)") + self.failHistoryRequest(requestId, reason: message) default: self.log("[Status] unsupported type=\(type)") @@ -6533,6 +6705,14 @@ extension BLETestManager: CBPeripheralDelegate { ? ",可重试\(payload.retryAfterMs.map { "(\($0)ms 后)" } ?? "")" : "" lastCommandErrorSummary = "\(correlatedCommand):\(payload.code)\(retryText)" + if let sequence = payload.sequence { + if let requestId = historyRequestSequences.first(where: { $0.value == sequence })?.key { + failHistoryRequest(requestId, reason: payload.code) + } + if lyricDiagnosticRequestSequence == sequence { + finishLyricDiagnosticRequest(error: "诊断暂不可用,请确认连接后重试") + } + } log( "[BLE-V3] commandError seq=\(payload.sequence.map { String($0) } ?? "-") " + "cmd=\(correlatedCommand) domain=\(payload.domain.rawValue) " + @@ -6631,8 +6811,10 @@ extension BLETestManager: CBPeripheralDelegate { let responseType = object["responseType"] as? String ?? "" let size = Self.intValue(object["size"]) let chunks = Self.intValue(object["chunks"]) - guard !requestId.isEmpty, !responseType.isEmpty, size > 0, chunks > 0 else { + guard pendingHistoryRequests[requestId] != nil else { return } + guard !responseType.isEmpty, size > 0, chunks > 0 else { log("[HistorySync] invalid payload start") + failHistoryRequest(requestId, reason: "无效的历史分包信息") return } historyPayloads[requestId] = HistoryPayloadAssembly( @@ -6652,6 +6834,7 @@ extension BLETestManager: CBPeripheralDelegate { let base64 = object["data"] as? String, let chunk = Data(base64Encoded: base64) else { log("[HistorySync] invalid payload chunk requestId=\(requestId) index=\(index)") + failHistoryRequest(requestId, reason: "无效的历史分包") return } assembly.chunks[index] = chunk @@ -6663,8 +6846,10 @@ extension BLETestManager: CBPeripheralDelegate { guard let assembly = historyPayloads.removeValue(forKey: requestId), assembly.chunks.count == assembly.expectedChunks else { log("[HistorySync] payload end missing chunks requestId=\(requestId)") + failHistoryRequest(requestId, reason: "历史分包不完整,请重试") return } + let token = historyTransferToken protocolDecodeQueue.async { [weak self] in guard let self else { return } let signpost = AppPerformanceLog.protocolSignposter.beginInterval("History Payload Decode") @@ -6674,7 +6859,8 @@ extension BLETestManager: CBPeripheralDelegate { guard let chunk = assembly.chunks[index] else { AppPerformanceLog.protocolSignposter.endInterval("History Payload Decode", signpost) DispatchQueue.main.async { [weak self] in - self?.log("[HistorySync] payload missing chunk requestId=\(requestId) index=\(index)") + guard let self, self.historyTransferToken == token else { return } + self.failHistoryRequest(requestId, reason: "历史分包不完整,请重试") } return } @@ -6685,9 +6871,13 @@ extension BLETestManager: CBPeripheralDelegate { : nil AppPerformanceLog.protocolSignposter.endInterval("History Payload Decode", signpost) DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard let decoded else { + guard let self, self.historyTransferToken == token, + self.pendingHistoryRequests[requestId] != nil else { return } + guard let decoded, + decoded["requestId"] as? String == requestId, + decoded["type"] as? String == assembly.responseType else { self.log("[HistorySync] payload decode failed requestId=\(requestId)") + self.failHistoryRequest(requestId, reason: "历史数据解析失败,请重试") return } self.log("[HistorySync] payload decoded requestId=\(requestId) bytes=\(data.count)") @@ -6699,12 +6889,25 @@ extension BLETestManager: CBPeripheralDelegate { private func handleHistoryPayload(_ object: [String: Any]) { let type = object["type"] as? String ?? "" let requestId = object["requestId"] as? String ?? "" + guard let kind = pendingHistoryRequests[requestId] else { return } + let expectedType: String + switch kind { + case .since: expectedType = "playHistorySince" + case .page: expectedType = "playHistoryPage" + case .stats: expectedType = "playStats" + } + guard type == expectedType else { + failHistoryRequest(requestId, reason: "历史响应类型不匹配") + return + } + let token = historyTransferToken switch type { case "playHistoryPage", "playHistorySince": let sessions = decodeHistorySessions(object["items"] as? [[String: Any]] ?? []) PlaybackHistoryStore.shared.mergeSessions(sessions) { [weak self] merged in DispatchQueue.main.async { - guard let self else { return } + guard let self, self.historyTransferToken == token, + self.pendingHistoryRequests[requestId] != nil else { return } self.playbackHistorySessions = merged self.handleHistoryRequestCompletion( type: type, @@ -6717,15 +6920,12 @@ extension BLETestManager: CBPeripheralDelegate { case "playStats": guard let stats = decodePlaybackStats(object) else { - pendingHistoryRequests.removeValue(forKey: requestId) - playbackHistoryStatus = "统计解析失败" - log("[HistorySync] stats decode failed requestId=\(requestId)") - requestNextPlaybackStats() + failHistoryRequest(requestId, reason: "统计解析失败") return } playbackStats[stats.range] = stats PlaybackHistoryStore.shared.saveStats(stats) - pendingHistoryRequests.removeValue(forKey: requestId) + _ = removeHistoryRequest(requestId) playbackHistoryStatus = "统计已更新" log("[HistorySync] stats updated range=\(stats.range)") requestNextPlaybackStats() @@ -6741,8 +6941,13 @@ extension BLETestManager: CBPeripheralDelegate { received: Int, response: [String: Any] ) { - let kind = pendingHistoryRequests.removeValue(forKey: requestId) let hasMore = response["hasMore"] as? Bool ?? false + if type == "playHistorySince", hasMore, + Self.int64Value(response["lastSessionId"]) <= lastSyncedHistorySessionId { + failHistoryRequest(requestId, reason: "历史同步游标未推进") + return + } + guard removeHistoryRequest(requestId) != nil else { return } if type == "playHistorySince" { let lastSessionId = Self.int64Value(response["lastSessionId"]) if lastSessionId > lastSyncedHistorySessionId { @@ -6769,8 +6974,6 @@ extension BLETestManager: CBPeripheralDelegate { isLoadingMoreHistory = false playbackHistoryStatus = received == 0 ? "没有更多历史" : "已加载更多" log("[HistorySync] page received=\(received) hasMore=\(hasMore)") - } else if kind == nil { - log("[HistorySync] response without pending requestId=\(requestId)") } } @@ -6899,7 +7102,7 @@ extension BLETestManager: CBPeripheralDelegate { translationLyricsState = .idle romanizationLyricsState = .idle lyricDiagnostic = nil - lyricDiagnosticLoading = false + finishLyricDiagnosticRequest() lyricDiagnosticLastUpdatedAt = nil mediaLoadingState.lyric = .waitingQqQrc mediaLoadingState.artwork = .preview(received: 0, expected: 0) @@ -7018,7 +7221,7 @@ extension BLETestManager: CBPeripheralDelegate { fullLyricsTrackId = "" isFullLyricsCurrent = false lyricDiagnostic = nil - lyricDiagnosticLoading = false + finishLyricDiagnosticRequest() lyricDiagnosticLastUpdatedAt = nil isPlaying = false updateProgressTimerState() @@ -7074,8 +7277,10 @@ extension BLETestManager: CBPeripheralDelegate { ) lastLiveActivityRequestAt = Date() lastLiveActivityRequestTrackID = snapshotTrackID + let backgroundTask = BoundedBackgroundTask(name: "Live Activity publication") Task { @MainActor in + defer { backgroundTask.end() } LiveActivityManager.shared.update( title: snapshotTitle, artist: snapshotArtist, @@ -7958,7 +8163,7 @@ extension BLETestManager: CBPeripheralDelegate { return } lyricDiagnostic = parseLyricDiagnostic(object) - lyricDiagnosticLoading = false + finishLyricDiagnosticRequest() lyricDiagnosticLastUpdatedAt = Date() if let diagnostic = lyricDiagnostic { if diagnostic.status == "waiting_qqmusic_cache" || @@ -7977,7 +8182,7 @@ extension BLETestManager: CBPeripheralDelegate { } private func handleLyricDiagnosticUnavailable(_ object: [String: Any]) { - lyricDiagnosticLoading = false + finishLyricDiagnosticRequest(error: "诊断暂不可用,请确认连接后重试") let reason = object["reason"] as? String ?? "unavailable" log("[LyricsDiag-iOS] unavailable reason=\(reason)") } @@ -9213,6 +9418,7 @@ extension BLETestManager: CBPeripheralDelegate { key: String, reason: String ) { + beginBackgroundArtworkTask(id: key) let trackAtStart = currentTrackID let revision = currentLiveArtworkRevision + 1 let writeRequest = liveArtworkRevisionFence.begin() @@ -9245,6 +9451,10 @@ extension BLETestManager: CBPeripheralDelegate { "revision=\(revision) source=\(reason)" ) self.updateLiveActivity(force: true, reason: "artworkReady") + if self.backgroundArtworkID == key { + self.backgroundArtworkTask?.end() + self.backgroundArtworkTask = nil + } } ) } @@ -9260,6 +9470,14 @@ extension BLETestManager: CBPeripheralDelegate { } } + private func beginBackgroundArtworkTask(id: String) { + guard appLifecycleState != "active", !id.isEmpty else { return } + if backgroundArtworkID == id, backgroundArtworkTask?.isActive == true { return } + backgroundArtworkTask?.end() + backgroundArtworkID = id + backgroundArtworkTask = BoundedBackgroundTask(name: "Current artwork preview") + } + private func finishRemoteLogTransfer() { guard remoteLogExpectedChunks > 0, remoteLogChunks.count == remoteLogExpectedChunks else { @@ -9466,6 +9684,10 @@ extension BLETestManager: AlbumArtReceiverDelegate { } func albumArtSendCommand(cmd: String, extra: [String: Any]) { + if cmd == "ALBUM_ART_REQUEST", extra["quality"] as? String == "preview", + let id = extra["id"] as? String { + beginBackgroundArtworkTask(id: id) + } sendCommand(cmd: cmd, extra: extra) } diff --git a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift index d12c8bf..21ef830 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit private final class BLETestManagerOwner: ObservableObject { let manager: BLETestManager @@ -19,6 +20,7 @@ struct ContentView: View { @State private var showSystemHealthOverview = false @State private var showPreferences = false @State private var showDeviceDetails = false + @State private var deferredDiagnostic: DeferredPlayerDiagnostic? private var manager: BLETestManager { managerOwner.manager } @@ -54,11 +56,12 @@ struct ContentView: View { .sheet(isPresented: $showPreferences) { PreferencesView(bleManager: manager, onDismiss: { showPreferences = false }) } - .sheet(isPresented: $showDeviceDetails) { + .sheet(isPresented: $showDeviceDetails, onDismiss: presentDeferredDiagnostic) { DeviceDetailView( manager: manager, onShowAdvancedDiagnostics: { - showNowPlayingDiagnostic = true + deferredDiagnostic = .nowPlaying + showDeviceDetails = false } ) } @@ -80,13 +83,13 @@ struct ContentView: View { onDismiss: { showSystemHealthOverview = false } ) } - .fullScreenCover(isPresented: $showFullLyrics) { + .fullScreenCover(isPresented: $showFullLyrics, onDismiss: presentDeferredDiagnostic) { FullLyricsStoreHost( manager: manager, onDismiss: { showFullLyrics = false }, onShowDiagnostic: { + deferredDiagnostic = .lyrics showFullLyrics = false - showLyricDiagnostic = true } ) } @@ -102,6 +105,7 @@ struct ContentView: View { } .onChange(of: preferences.appExperienceMode) { _, mode in if mode == .daily { + deferredDiagnostic = nil showDebugPage = false showLyricDiagnostic = false showNowPlayingDiagnostic = false @@ -110,6 +114,21 @@ struct ContentView: View { } } } + + private func presentDeferredDiagnostic() { + let diagnostic = deferredDiagnostic + deferredDiagnostic = nil + switch diagnostic { + case .nowPlaying: showNowPlayingDiagnostic = true + case .lyrics: showLyricDiagnostic = true + case nil: break + } + } +} + +private enum DeferredPlayerDiagnostic { + case nowPlaying + case lyrics } private struct PlayerBackgroundHost: View { @@ -340,7 +359,7 @@ private struct PlayerHeaderStoreView: View { Image(systemName: "ellipsis") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(.white.opacity(0.86)) - .frame(width: 42, height: 42) + .frame(width: 44, height: 44) .background(.white.opacity(0.05), in: Circle()) .overlay { Circle().stroke(.white.opacity(0.08), lineWidth: 1) } } @@ -858,7 +877,7 @@ private struct PlaybackProgressStoreView: View { } } -private struct CompactPlayerSlider: View { +struct CompactPlayerSlider: View { let value: Binding let range: ClosedRange let step: Double? @@ -869,74 +888,137 @@ private struct CompactPlayerSlider: View { let onEditingChanged: (Bool) -> Void var body: some View { - GeometryReader { proxy in - let thumbDiameter = CompactSliderPresentation.thumbDiameter - let trackWidth = max(proxy.size.width - thumbDiameter, 0) - let progress = CGFloat( - CompactSliderPresentation.normalizedProgress( - value: value.wrappedValue, - lowerBound: range.lowerBound, - upperBound: range.upperBound - ) - ) - let fillWidth = trackWidth * progress - let centerY = proxy.size.height / 2 - let thumbX = thumbDiameter / 2 + fillWidth - - ZStack(alignment: .topLeading) { - Capsule() - .fill(.white.opacity(0.18)) - .frame(width: trackWidth, height: CompactSliderPresentation.trackHeight) - .position(x: proxy.size.width / 2, y: centerY) + CompactNativePlayerSlider(configuration: self) + .disabled(!isEnabled) + .frame(height: CompactSliderPresentation.interactionHeight) + } +} - Capsule() - .fill(accentColor) - .frame(width: fillWidth, height: CompactSliderPresentation.trackHeight) - .position(x: thumbDiameter / 2 + fillWidth / 2, y: centerY) +private struct CompactNativePlayerSlider: UIViewRepresentable { + let configuration: CompactPlayerSlider - Circle() - .fill(.white) - .frame(width: thumbDiameter, height: thumbDiameter) - .overlay { - Circle().strokeBorder(accentColor.opacity(0.72), lineWidth: 1) - } - .shadow(color: .black.opacity(0.24), radius: 2, y: 1) - .position(x: thumbX, y: centerY) - .allowsHitTesting(false) + func makeCoordinator() -> Coordinator { Coordinator(parent: configuration) } - interactiveSlider - .frame(width: proxy.size.width, height: proxy.size.height) - .opacity(0.001) + func makeUIView(context: Context) -> CompactSliderControl { + let slider = CompactSliderControl() + slider.addTarget(context.coordinator, action: #selector(Coordinator.beginEditing), for: .touchDown) + slider.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged) + slider.addTarget( + context.coordinator, + action: #selector(Coordinator.endEditing), + for: [.touchUpInside, .touchUpOutside, .touchCancel] + ) + slider.onAccessibilityAdjustment = { [weak coordinator = context.coordinator] control, direction in + coordinator?.adjustAccessibility(control, direction: direction) + } + return slider + } + + func updateUIView(_ slider: CompactSliderControl, context: Context) { + context.coordinator.parent = configuration + slider.minimumValue = Float(configuration.range.lowerBound) + slider.maximumValue = Float(configuration.range.upperBound) + slider.isEnabled = configuration.isEnabled + if !configuration.isEnabled, context.coordinator.isEditing { + let coordinator = context.coordinator + DispatchQueue.main.async { [weak coordinator] in + guard let coordinator, !coordinator.parent.isEnabled else { return } + coordinator.endEditing() } } - .frame(height: CompactSliderPresentation.interactionHeight) - .contentShape(Rectangle()) - .opacity(isEnabled ? 1 : 0.45) + if !context.coordinator.isEditing { + slider.value = Float(min( + max(configuration.value.wrappedValue, configuration.range.lowerBound), + configuration.range.upperBound + )) + } + slider.applyTint(UIColor(configuration.accentColor)) + slider.alpha = configuration.isEnabled ? 1 : 0.45 + slider.accessibilityLabel = AppLocalization.string(configuration.accessibilityLabel) + slider.accessibilityValue = configuration.accessibilityValue } - @ViewBuilder - private var interactiveSlider: some View { - if let step { - Slider( - value: value, - in: range, - step: step, - onEditingChanged: onEditingChanged - ) - .disabled(!isEnabled) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(accessibilityValue) - } else { - Slider( - value: value, - in: range, - onEditingChanged: onEditingChanged + func sizeThatFits(_ proposal: ProposedViewSize, uiView: CompactSliderControl, context: Context) -> CGSize? { + CGSize(width: proposal.width ?? 160, height: CompactSliderPresentation.interactionHeight) + } + + final class Coordinator: NSObject { + var parent: CompactPlayerSlider + private(set) var isEditing = false + + init(parent: CompactPlayerSlider) { self.parent = parent } + + @objc func beginEditing() { + guard parent.isEnabled, !isEditing else { return } + isEditing = true + parent.onEditingChanged(true) + } + + @objc func valueChanged(_ slider: UISlider) { + guard parent.isEnabled else { return } + // Non-touch changes must commit as one complete edit, too. + let completesImmediately = !isEditing + beginEditing() + var newValue = Double(slider.value) + if let step = parent.step, step > 0 { + newValue = parent.range.lowerBound + + ((newValue - parent.range.lowerBound) / step).rounded() * step + } + newValue = min(max(newValue, parent.range.lowerBound), parent.range.upperBound) + slider.value = Float(newValue) + parent.value.wrappedValue = newValue + if completesImmediately { endEditing() } + } + + @objc func endEditing() { + guard isEditing else { return } + isEditing = false + parent.onEditingChanged(false) + } + + func adjustAccessibility(_ slider: UISlider, direction: Double) { + guard parent.isEnabled else { return } + let increment = parent.step ?? (parent.range.upperBound - parent.range.lowerBound) / 20 + let newValue = min( + max(parent.value.wrappedValue + direction * increment, parent.range.lowerBound), + parent.range.upperBound ) - .disabled(!isEnabled) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(accessibilityValue) + guard newValue != parent.value.wrappedValue else { return } + slider.value = Float(newValue) + valueChanged(slider) + } + } +} + +final class CompactSliderControl: UISlider { + var onAccessibilityAdjustment: ((CompactSliderControl, Double) -> Void)? + private var appliedTint: UIColor? + + override func trackRect(forBounds bounds: CGRect) -> CGRect { + let track = super.trackRect(forBounds: bounds) + return CGRect(x: track.minX, y: bounds.midY - 1.5, width: track.width, height: 3) + } + + func applyTint(_ color: UIColor) { + guard appliedTint != color else { return } + appliedTint = color + minimumTrackTintColor = color + maximumTrackTintColor = UIColor.white.withAlphaComponent(0.18) + let image = UIGraphicsImageRenderer(size: CGSize(width: 11, height: 11)).image { _ in + let circle = UIBezierPath(ovalIn: CGRect(x: 0.5, y: 0.5, width: 10, height: 10)) + UIColor.white.setFill() + circle.fill() + color.withAlphaComponent(0.72).setStroke() + circle.lineWidth = 1 + circle.stroke() } + setThumbImage(image, for: .normal) + setThumbImage(image, for: .highlighted) + setThumbImage(image, for: .disabled) } + + override func accessibilityIncrement() { onAccessibilityAdjustment?(self, 1) } + override func accessibilityDecrement() { onAccessibilityAdjustment?(self, -1) } } private struct PlaybackControlsStoreView: View { @@ -1024,7 +1106,7 @@ private struct VolumeControlStoreView: View { .frame(height: CompactSliderPresentation.interactionHeight) } .padding(.horizontal, 11) - .frame(height: 36) + .frame(height: CompactSliderPresentation.interactionHeight) .background(.black.opacity(0.13), in: RoundedRectangle(cornerRadius: 16)) .overlay { RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.06)) } .opacity(isAvailable ? 1 : 0.48) @@ -1236,9 +1318,7 @@ struct CompactVolumePresentation { } struct CompactSliderPresentation { - static let trackHeight: CGFloat = 3 - static let thumbDiameter: CGFloat = 11 - static let interactionHeight: CGFloat = 32 + static let interactionHeight: CGFloat = 44 static func normalizedProgress( value: Double, diff --git a/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift index 7fcf329..ef49ec0 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/FullLyricsView.swift @@ -150,6 +150,8 @@ struct FullLyricsView: View { .overlay { Circle().stroke(.white.opacity(0.10), lineWidth: 1) } + .padding(5) + .contentShape(Rectangle()) } .buttonStyle(FullLyricsPressStyle()) .accessibilityLabel("关闭歌词") @@ -203,6 +205,7 @@ struct FullLyricsView: View { .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 8) + .frame(minHeight: 44) .background(.white.opacity(0.12), in: Capsule()) .overlay { Capsule().stroke(.white.opacity(0.10), lineWidth: 1) @@ -240,7 +243,7 @@ struct FullLyricsView: View { .font(.callout.weight(.bold)) .foregroundStyle(.white) .padding(.horizontal, 16) - .frame(height: 36) + .frame(height: 44) .background(.white.opacity(0.12), in: Capsule()) } .buttonStyle(.plain) @@ -279,6 +282,7 @@ struct FullLyricsView: View { .transition(.opacity) } } + .frame(minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -393,7 +397,7 @@ struct FullLyricsView: View { private var controls: some View { HStack(spacing: 32) { - controlButton(systemImage: "backward.fill", size: 52, action: onPrevious) + controlButton("上一首", systemImage: "backward.fill", size: 52, action: onPrevious) Button(action: onPlayPause) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 30, weight: .bold)) @@ -404,7 +408,7 @@ struct FullLyricsView: View { } .buttonStyle(FullLyricsPressStyle(pressedScale: 0.92)) .accessibilityLabel(isPlaying ? "暂停" : "播放") - controlButton(systemImage: "forward.fill", size: 52, action: onNext) + controlButton("下一首", systemImage: "forward.fill", size: 52, action: onNext) } .padding(.top, 4) .disabled(!isConnected) @@ -412,6 +416,7 @@ struct FullLyricsView: View { } private func controlButton( + _ title: String, systemImage: String, size: CGFloat, action: @escaping () -> Void @@ -427,6 +432,7 @@ struct FullLyricsView: View { } } .buttonStyle(FullLyricsPressStyle(pressedScale: 0.92)) + .accessibilityLabel(AppLocalization.string(title)) } private func scrollToCurrent(_ proxy: ScrollViewProxy) { diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift index ae4dd68..f2c9c23 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityArtworkStore.swift @@ -1,29 +1,62 @@ import Foundation import UIKit +/// A finite lease for BLE wake-up work and its asynchronous publication. +final class BoundedBackgroundTask { + private var identifier: UIBackgroundTaskIdentifier = .invalid + private var deadline: DispatchWorkItem? + var isActive: Bool { identifier != .invalid } + + init(name: String) { + guard UIApplication.shared.applicationState != .active else { return } + identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + self?.end() + } + guard isActive else { return } + let item = DispatchWorkItem { [weak self] in self?.end() } + deadline = item + DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: item) + } + + func end() { + deadline?.cancel() + deadline = nil + guard isActive else { return } + let task = identifier + identifier = .invalid + UIApplication.shared.endBackgroundTask(task) + } + + deinit { end() } +} + struct LiveActivityArtworkWriteResult: Sendable { let succeeded: Bool let messages: [String] } -private actor LiveActivityArtworkFileWriter { +actor LiveActivityArtworkFileWriter { private let maxCachedFiles = 50 + private let directoryOverride: URL? + + init(directoryOverride: URL? = nil) { self.directoryOverride = directoryOverride } + + private func directoryURL(fileManager: FileManager) -> URL? { + if let directoryOverride { return directoryOverride } + return fileManager.containerURL( + forSecurityApplicationGroupIdentifier: LiveActivitySharedConstants.appGroupIdentifier + )?.appendingPathComponent(LiveActivitySharedConstants.artworkDirectoryName, isDirectory: true) + } func write(data: Data, key: String, revision: Int) -> LiveActivityArtworkWriteResult { var messages = ["[LiveArtwork] write start key=\(key) revision=\(revision)"] let fileManager = FileManager.default - guard let containerURL = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: LiveActivitySharedConstants.appGroupIdentifier - ) else { + guard let directoryURL = directoryURL(fileManager: fileManager) else { messages.append("[LiveArtwork] file validation failed reason=container unavailable") return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) } - let directoryURL = containerURL.appendingPathComponent( - LiveActivitySharedConstants.artworkDirectoryName, - isDirectory: true - ) - messages.append("[LiveArtwork] group container=\(containerURL.path)") + messages.append("[LiveArtwork] group container=\(directoryURL.deletingLastPathComponent().path)") do { try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -35,7 +68,7 @@ private actor LiveActivityArtworkFileWriter { let fileURL = directoryURL.appendingPathComponent( LiveActivitySharedConstants.artworkFileName(key: key, revision: revision) ) - try data.write(to: fileURL, options: .atomic) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) guard fileManager.fileExists(atPath: fileURL.path) else { messages.append("[LiveArtwork] file validation failed reason=file missing") return LiveActivityArtworkWriteResult(succeeded: false, messages: messages) @@ -52,13 +85,7 @@ private actor LiveActivityArtworkFileWriter { func removeAll() { let fileManager = FileManager.default - guard let containerURL = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: LiveActivitySharedConstants.appGroupIdentifier - ) else { return } - let directoryURL = containerURL.appendingPathComponent( - LiveActivitySharedConstants.artworkDirectoryName, - isDirectory: true - ) + guard let directoryURL = directoryURL(fileManager: fileManager) else { return } guard let urls = try? fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: nil diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift index 9a24d8c..0679ad7 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift @@ -362,8 +362,10 @@ final class LiveActivityManager { return } updateInFlight = true + let backgroundTask = BoundedBackgroundTask(name: "Live Activity update") Task { [weak self] in + defer { backgroundTask.end() } guard let self else { return } let startedAt = Date() logger?("[LiveActivityPerf] update start reason=\(reason)") @@ -489,16 +491,13 @@ final class LiveActivityManager { for await state in activity.activityStateUpdates { await MainActor.run { logger?("[LiveActivity] state changed state=\(state)") - switch state { - case .active, .stale: + if state == .active || state == .stale { self?.activity = activity - case .ended, .dismissed: + } else if state == .ended || state == .dismissed { if self?.activity?.id == activity.id { self?.activity = nil } self?.startCooldownUntil = Date().addingTimeInterval(5) - @unknown default: - break } } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings index 20335e7..6fe1288 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings +++ b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings @@ -1,6 +1,8 @@ { "sourceLanguage" : "zh-Hans", "strings" : { + "诊断暂不可用,请确认连接后重试" : {"localizations" : {"en" : {"stringUnit" : {"state" : "translated", "value" : "Diagnostics unavailable. Check the connection and try again."}}}}, + "诊断请求超时,请重试" : {"localizations" : {"en" : {"stringUnit" : {"state" : "translated", "value" : "Diagnostics request timed out. Try again."}}}}, "Sony 已连接" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sony connected" } } } }, "上一次" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous" } } } }, "上一首" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous track" } } } }, diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnosticView.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnosticView.swift index 5a445ea..4e309be 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnosticView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricDiagnosticView.swift @@ -65,14 +65,20 @@ struct LyricDiagnosticView: View { Divider().overlay(.white.opacity(0.12)) - Text(diagnostic?.statusTitle ?? "正在获取诊断") + Text(diagnostic?.statusTitle ?? (bleManager.lyricDiagnosticLoading + ? "正在获取诊断" + : AppLocalization.string("诊断暂不可用,请确认连接后重试"))) .font(.headline.weight(.semibold)) .foregroundStyle(.white) - Text(diagnostic?.humanReadableReason ?? "正在向 Sony 读取当前歌词状态。") + Text(bleManager.lyricDiagnosticRequestError.map { AppLocalization.string($0) } + ?? diagnostic?.humanReadableReason + ?? "正在向 Sony 读取当前歌词状态。") .font(.subheadline) .foregroundStyle(.white.opacity(0.72)) .fixedSize(horizontal: false, vertical: true) - Text(diagnostic?.suggestionText ?? "请稍等片刻。") + Text(diagnostic?.suggestionText ?? (bleManager.lyricDiagnosticLoading + ? "请稍等片刻。" + : AppLocalization.string("刷新诊断"))) .font(.callout.weight(.medium)) .foregroundStyle(.white.opacity(0.84)) .fixedSize(horizontal: false, vertical: true) diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift index afb5ffd..5d4f7a0 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift @@ -1466,8 +1466,351 @@ final class PerformanceStabilityTests: XCTestCase { 1, accuracy: 0.001 ) - XCTAssertLessThan(CompactSliderPresentation.thumbDiameter, 16) - XCTAssertGreaterThanOrEqual(CompactSliderPresentation.interactionHeight, 28) + XCTAssertGreaterThanOrEqual(CompactSliderPresentation.interactionHeight, 44) + } + + @MainActor + func testPlayerSliderParticipatesInHitTesting() throws { + var value = 5.0 + try withHostedPlayerSlider(value: Binding(get: { value }, set: { value = $0 })) { slider, window in + var effectiveAlpha: CGFloat = 1 + var ancestor: UIView? = slider + while let view = ancestor { + effectiveAlpha *= view.alpha + ancestor = view.superview + } + XCTAssertGreaterThan(effectiveAlpha, 0.01, "The real control must stay in the hit-test tree") + let point = slider.convert(CGPoint(x: slider.bounds.midX, y: slider.bounds.midY), to: window) + let hitView = try XCTUnwrap(window.hitTest(point, with: nil)) + XCTAssertTrue(hitView === slider || hitView.isDescendant(of: slider), "Visible track must hit the actual slider") + XCTAssertGreaterThanOrEqual(slider.bounds.height, 44) + } + } + + @MainActor + func testPlayerSliderDragUpdatesAndFinishesOnce() throws { + for step: Double? in [1, nil] { + var value = 5.0 + var edits: [Bool] = [] + try withHostedPlayerSlider( + value: Binding(get: { value }, set: { value = $0 }), + step: step, + onEditingChanged: { edits.append($0) } + ) { slider, _ in + slider.sendActions(for: .touchDown) + slider.value = 8 + slider.sendActions(for: .valueChanged) + slider.value = 12 + slider.sendActions(for: .valueChanged) + XCTAssertEqual(value, 12, accuracy: 0.001) + XCTAssertEqual(edits, [true], "Dragging must keep one edit open until release") + slider.sendActions(for: .touchUpInside) + XCTAssertEqual(edits, [true, false]) + } + } + } + + @MainActor + func testPlayerSliderAccessibilityAdjustmentCompletesEdit() throws { + for step: Double? in [1, nil] { + var value = 5.0 + var edits: [Bool] = [] + try withHostedPlayerSlider( + value: Binding(get: { value }, set: { value = $0 }), + step: step, + onEditingChanged: { edits.append($0) } + ) { slider, _ in + slider.accessibilityIncrement() + XCTAssertGreaterThan(value, 5) + XCTAssertEqual(edits, [true, false], "A non-touch adjustment must commit seek/volume state") + } + } + } + + @MainActor + func testDisabledPlayerSliderDoesNotChangeValueOrStartEditing() throws { + var value = 5.0 + var edits: [Bool] = [] + try withHostedPlayerSlider( + value: Binding(get: { value }, set: { value = $0 }), + isEnabled: false, + onEditingChanged: { edits.append($0) } + ) { slider, _ in + XCTAssertFalse(slider.isEnabled) + slider.sendActions(for: .touchDown) + slider.value = 12 + slider.sendActions(for: .valueChanged) + slider.sendActions(for: .touchUpInside) + slider.accessibilityIncrement() + XCTAssertEqual(value, 5) + XCTAssertTrue(edits.isEmpty) + } + } + + @MainActor + private func withHostedPlayerSlider( + value: Binding, + step: Double? = 1, + isEnabled: Bool = true, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + assertions: (UISlider, UIWindow) throws -> Void + ) throws { + let host = UIHostingController(rootView: CompactPlayerSlider( + value: value, + range: 0...15, + step: step, + accentColor: .green, + isEnabled: isEnabled, + accessibilityLabel: "Volume", + accessibilityValue: "5 of 15", + onEditingChanged: onEditingChanged + ).frame(width: 280, height: 44)) + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + let previousKeyWindow = scene?.windows.first(where: \.isKeyWindow) + let window = scene.map(UIWindow.init(windowScene:)) ?? UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 160)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previousKeyWindow?.makeKeyAndVisible() + } + host.view.layoutIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + host.view.layoutIfNeeded() + + func findSlider(in view: UIView) -> UISlider? { + if let slider = view as? UISlider { return slider } + return view.subviews.lazy.compactMap { findSlider(in: $0) }.first + } + let slider = try XCTUnwrap(findSlider(in: host.view)) + try assertions(slider, window) + } + + @MainActor + func testLiveActivityArtworkWriterPublishesReadableRevisionAndRejectsOversizedData() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let writer = LiveActivityArtworkFileWriter(directoryOverride: directory) + let image = UIGraphicsImageRenderer(size: CGSize(width: 80, height: 80)).image { context in + UIColor.green.setFill() + context.fill(CGRect(x: 0, y: 0, width: 80, height: 80)) + } + let data = try XCTUnwrap(image.jpegData(compressionQuality: 0.78)) + let result = await writer.write(data: data, key: "fixture", revision: 1) + XCTAssertTrue(result.succeeded) + let url = directory.appendingPathComponent(LiveActivitySharedConstants.artworkFileName(key: "fixture", revision: 1)) + XCTAssertEqual(try Data(contentsOf: url), data) + XCTAssertNotNil(UIImage(contentsOfFile: url.path)) + let oversized = await writer.write(data: Data(repeating: 0, count: 20_000), key: "fixture", revision: 2) + XCTAssertFalse(oversized.succeeded) + XCTAssertEqual(try Data(contentsOf: url), data, "A failed write must retain the last published revision") + await writer.removeAll() + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testBackgroundCommandPolicyAllowsCurrentPreviewWithoutUnfreezingHeavyWork() { + func allowed(_ command: String, quality: String? = nil, id: String? = nil, control: Bool = false) -> Bool { + CommandWriteQueuePolicy.canRun( + appIsActive: false, isControl: control, command: command, + artworkID: id, artworkQuality: quality, currentArtworkID: "current" + ) + } + XCTAssertTrue(allowed("ALBUM_ART_REQUEST", quality: "preview", id: "current")) + XCTAssertFalse(allowed("ALBUM_ART_REQUEST", quality: "hq", id: "current")) + XCTAssertFalse(allowed("ALBUM_ART_REQUEST", quality: "preview", id: "old")) + XCTAssertFalse(allowed("GET_PLAY_HISTORY_SINCE")) + XCTAssertFalse(allowed("GET_LYRIC_DIAGNOSTIC")) + XCTAssertTrue(allowed("NEXT", control: true)) + let requests = [("GET_PLAY_HISTORY_SINCE", ""), ("ALBUM_ART_REQUEST", "hq"), ("ALBUM_ART_REQUEST", "preview")] + XCTAssertEqual(requests.firstIndex { allowed($0.0, quality: $0.1, id: "current") }, 2) + } + + @MainActor + func testHistorySuccessfulPaginationStatsAndLoadMoreReleaseBusyState() async throws { + let store = PlaybackHistoryStore.shared + await clearPlaybackHistory(store) + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + defer { manager.disconnectResponseTests() } + var requests: [(command: String, payload: [String: Any])] = [] + let secondPageRequested = expectation(description: "next history page requested") + let statsRequested = expectation(description: "statistics requested after history completes") + manager.commandSenderForTesting = { command, payload in + requests.append((command, payload)) + if command == "GET_PLAY_HISTORY_SINCE", + requests.filter({ $0.command == command }).count == 2 { + secondPageRequested.fulfill() + } + if command == "GET_PLAY_STATS", payload["range"] as? String == "TODAY" { + statsRequested.fulfill() + } + return true + } + + manager.syncPlaybackHistory() + let firstID = try XCTUnwrap(requests.last?.payload["requestId"] as? String) + manager.receiveStatusForTesting(["type": "playHistorySince", "requestId": firstID, + "items": [["sessionId": 1, "title": "First"]], "hasMore": true, "lastSessionId": 1]) + await fulfillment(of: [secondPageRequested], timeout: 2) + XCTAssertTrue(manager.isPlaybackHistorySyncing) + XCTAssertEqual(requests.last?.payload["afterSessionId"] as? Int64, 1) + let secondID = try XCTUnwrap(requests.last?.payload["requestId"] as? String) + XCTAssertNotEqual(firstID, secondID) + manager.receiveStatusForTesting(["type": "playHistorySince", "requestId": secondID, + "items": [["sessionId": 2, "title": "Second"]], "hasMore": false, "lastSessionId": 2]) + await fulfillment(of: [statsRequested], timeout: 2) + XCTAssertFalse(manager.isPlaybackHistorySyncing) + XCTAssertEqual(manager.playbackHistorySessions.map(\.sessionId), [2, 1]) + let savedSyncState = await loadPlaybackSyncState(store) + XCTAssertEqual(savedSyncState.lastSyncedSessionId, 2) + + for range in ["TODAY", "WEEK", "MONTH"] { + XCTAssertEqual(requests.last?.payload["range"] as? String, range) + let requestID = try XCTUnwrap(requests.last?.payload["requestId"] as? String) + manager.receiveStatusForTesting(["type": "playStats", "requestId": requestID, + "range": range, "playCount": 2]) + } + XCTAssertEqual(Set(manager.playbackStats.keys), Set(["TODAY", "WEEK", "MONTH"])) + XCTAssertEqual(manager.playbackStats["WEEK"]?.playCount, 2) + + manager.loadMorePlaybackHistory() + XCTAssertEqual(requests.last?.command, "GET_PLAY_HISTORY_PAGE") + XCTAssertEqual(requests.last?.payload["beforeSessionId"] as? Int64, 1) + let requestsWhileLoading = requests.count + manager.loadMorePlaybackHistory() + XCTAssertEqual(requests.count, requestsWhileLoading, "Loading must prevent a duplicate page request") + let olderID = try XCTUnwrap(requests.last?.payload["requestId"] as? String) + manager.receiveStatusForTesting(["type": "playHistoryPage", "requestId": olderID, + "items": [], "hasMore": false]) + let pageComplete = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in manager.playbackHistoryStatus == "没有更多历史" }, object: nil + ) + await fulfillment(of: [pageComplete], timeout: 2) + XCTAssertEqual(manager.playbackHistorySessions.map(\.sessionId), [2, 1]) + manager.loadMorePlaybackHistory() + XCTAssertEqual(requests.count, requestsWhileLoading + 1, "A completed page must allow another request") + await clearPlaybackHistory(store) + } + + @MainActor + func testHistoryMissingChunksAndDisconnectReleaseBusyStateAndIgnoreOldResponses() throws { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + var requests: [[String: Any]] = [] + manager.commandSenderForTesting = { _, payload in requests.append(payload); return true } + defer { manager.disconnectResponseTests() } + manager.syncPlaybackHistory() + let firstID = try XCTUnwrap(requests.last?["requestId"] as? String) + XCTAssertTrue(manager.isPlaybackHistorySyncing) + manager.receiveStatusForTesting(["type": "historyPayloadStart", "requestId": firstID, + "responseType": "playHistorySince", "size": 10, "chunks": 2]) + manager.receiveStatusForTesting(["type": "historyPayloadEnd", "requestId": firstID]) + XCTAssertFalse(manager.isPlaybackHistorySyncing) + manager.syncPlaybackHistory() + XCTAssertTrue(manager.isPlaybackHistorySyncing) + manager.disconnectResponseTests() + XCTAssertFalse(manager.isPlaybackHistorySyncing) + manager.configureResponseTests() + manager.syncPlaybackHistory() + let newID = try XCTUnwrap(requests.last?["requestId"] as? String) + XCTAssertNotEqual(firstID, newID) + manager.receiveStatusForTesting(["type": "playHistorySince", "requestId": firstID, + "items": [], "hasMore": false, "lastSessionId": 0]) + XCTAssertTrue(manager.isPlaybackHistorySyncing, "An old response must not finish a new request") + } + + @MainActor + func testHistoryMalformedPayloadFailsAndAllowsRetry() async throws { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + var requestID = "" + manager.commandSenderForTesting = { _, payload in requestID = payload["requestId"] as? String ?? ""; return true } + defer { manager.disconnectResponseTests() } + manager.syncPlaybackHistory() + manager.receiveStatusForTesting(["type": "historyPayloadStart", "requestId": requestID, + "responseType": "playHistorySince", "size": 4, "chunks": 1]) + manager.receiveStatusForTesting(["type": "historyPayloadChunk", "requestId": requestID, + "index": 0, "data": Data("nope".utf8).base64EncodedString()]) + manager.receiveStatusForTesting(["type": "historyPayloadEnd", "requestId": requestID]) + let failed = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !manager.isPlaybackHistorySyncing }, object: nil) + await fulfillment(of: [failed], timeout: 2) + manager.syncPlaybackHistory() + XCTAssertTrue(manager.isPlaybackHistorySyncing) + } + + @MainActor + func testHistoryTimeoutAndSendFailureAllowRetry() async { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + manager.commandSenderForTesting = { _, _ in false } + defer { manager.disconnectResponseTests() } + manager.syncPlaybackHistory() + XCTAssertFalse(manager.isPlaybackHistorySyncing) + manager.commandSenderForTesting = { _, _ in true } + manager.responseTimeoutForTesting = 0.05 + manager.syncPlaybackHistory() + XCTAssertTrue(manager.isPlaybackHistorySyncing) + let timedOut = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !manager.isPlaybackHistorySyncing }, object: nil) + await fulfillment(of: [timedOut], timeout: 2) + manager.syncPlaybackHistory() + XCTAssertTrue(manager.isPlaybackHistorySyncing) + } + + @MainActor + func testStatsFailureAdvancesQueueAndDisconnectAllowsFreshRefresh() throws { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + var requests: [[String: Any]] = [] + manager.commandSenderForTesting = { _, payload in requests.append(payload); return true } + defer { manager.disconnectResponseTests() } + manager.refreshPlaybackStats() + XCTAssertEqual(requests.last?["range"] as? String, "TODAY") + let requestID = try XCTUnwrap(requests.last?["requestId"] as? String) + manager.receiveStatusForTesting(["type": "playHistoryError", "requestId": requestID, "message": "failure"]) + XCTAssertEqual(requests.last?["range"] as? String, "WEEK") + manager.disconnectResponseTests() + manager.configureResponseTests() + manager.refreshPlaybackStats() + XCTAssertEqual(requests.last?["range"] as? String, "TODAY") + } + + @MainActor + func testDiagnosticSendFailureAndDisconnectedRefreshDoNotStayLoading() { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + var attempts = 0 + manager.commandSenderForTesting = { _, _ in attempts += 1; return false } + manager.requestLyricDiagnostic(manual: true) + XCTAssertFalse(manager.lyricDiagnosticLoading) + XCTAssertNotNil(manager.lyricDiagnosticRequestError) + XCTAssertEqual(attempts, 1) + manager.disconnectResponseTests() + manager.requestLyricDiagnostic(manual: true) + XCTAssertFalse(manager.lyricDiagnosticLoading) + XCTAssertEqual(attempts, 1, "A cached song must not trigger an offline request") + } + + @MainActor + func testDiagnosticDeadlineIsCancelledByNewRequestAndSuccessfulResponse() async { + let manager = BLETestManager(automaticallyStartBluetooth: false) + manager.configureResponseTests() + manager.commandSenderForTesting = { _, _ in true } + defer { manager.disconnectResponseTests() } + manager.responseTimeoutForTesting = 0.05 + manager.requestLyricDiagnostic(manual: true) + manager.responseTimeoutForTesting = 1 + manager.requestLyricDiagnostic(manual: true) + try? await Task.sleep(nanoseconds: 120_000_000) + XCTAssertTrue(manager.lyricDiagnosticLoading, "The cancelled old deadline must not finish the new request") + manager.receiveStatusForTesting(["type": "lyricDiagnostic", "trackId": "fixture-track", "status": "loaded"]) + XCTAssertFalse(manager.lyricDiagnosticLoading) + XCTAssertNil(manager.lyricDiagnosticRequestError) + manager.responseTimeoutForTesting = 0.05 + manager.requestLyricDiagnostic(manual: true) + let timedOut = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in !manager.lyricDiagnosticLoading }, object: nil) + await fulfillment(of: [timedOut], timeout: 2) + XCTAssertEqual(manager.lyricDiagnosticRequestError, "诊断请求超时,请重试") } func testA1AndA2DispatchAndOutOfOrderAssembly() { diff --git a/PlayerAgentApp/build.gradle b/PlayerAgentApp/build.gradle index 8221927..f2ba492 100644 --- a/PlayerAgentApp/build.gradle +++ b/PlayerAgentApp/build.gradle @@ -25,6 +25,7 @@ android { } dependencies { + implementation "androidx.core:core-ktx:1.13.1" implementation "androidx.room:room-runtime:2.6.1" implementation "androidx.recyclerview:recyclerview:1.3.2" kapt "androidx.room:room-compiler:2.6.1" diff --git a/PlayerAgentApp/src/main/AndroidManifest.xml b/PlayerAgentApp/src/main/AndroidManifest.xml index b97ce35..e2c9736 100644 --- a/PlayerAgentApp/src/main/AndroidManifest.xml +++ b/PlayerAgentApp/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt index 85bc635..76d3143 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/MainActivity.kt @@ -27,6 +27,7 @@ import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.content.ContextCompat import com.example.playeragent.ble.ControllerScannerManager import com.example.playeragent.ble.BleHealthState import com.example.playeragent.classicbluetooth.RfcommClientManager @@ -252,11 +253,7 @@ class MainActivity : Activity() { addAction(PlayerAgentForegroundService.ACTION_PLAYER_UI_STATE) addAction(PlayerAgentForegroundService.ACTION_QRC_WATCHER_STATUS) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(logReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - registerReceiver(logReceiver, filter) - } + ContextCompat.registerReceiver(this, logReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) PlayerNotificationListenerService.removeQqMusicArtworkListener( qqMusicArtworkListener ) @@ -2272,6 +2269,7 @@ class MainActivity : Activity() { permissions += Manifest.permission.BLUETOOTH_ADVERTISE permissions += Manifest.permission.BLUETOOTH_CONNECT } else { + permissions += Manifest.permission.ACCESS_COARSE_LOCATION permissions += Manifest.permission.ACCESS_FINE_LOCATION permissions += Manifest.permission.READ_EXTERNAL_STORAGE } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt index 7ca70f9..d1a0677 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/BleNotifyQueue.kt @@ -5,6 +5,7 @@ import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothStatusCodes import android.os.Build import android.os.Handler import android.os.HandlerThread @@ -1406,7 +1407,7 @@ class BleNotifyQueue( characteristic, false, value - ) == BluetoothGatt.GATT_SUCCESS + ) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") characteristic.value = value diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/ControllerGattClientManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/ControllerGattClientManager.kt index 719ff43..f6b5cd5 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/ble/ControllerGattClientManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/ble/ControllerGattClientManager.kt @@ -7,6 +7,7 @@ import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes import android.content.Context import android.os.Build import android.os.Handler @@ -216,7 +217,7 @@ class ControllerGattClientManager( gatt.writeDescriptor( descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - ) == BluetoothGatt.GATT_SUCCESS + ) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE @@ -271,7 +272,7 @@ class ControllerGattClientManager( characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT - ) == BluetoothGatt.GATT_SUCCESS + ) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentLyricProbe.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentLyricProbe.kt index 71bed8b..976170e 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentLyricProbe.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentLyricProbe.kt @@ -325,10 +325,14 @@ object CurrentLyricProbe { ) } - private fun containsChinese(value: String): Boolean { + internal fun containsChinese(value: String): Boolean { return value.any { character -> - Character.UnicodeScript.of(character.code) == - Character.UnicodeScript.HAN + when (Character.UnicodeBlock.of(character)) { + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A, + Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS -> true + else -> character == '\u3007' + } } } diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt index 063309e..4b7a6e4 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt @@ -21,13 +21,12 @@ class QrcLyricCacheManager( context = appContext, logger = logger ) - private val parsedIndexStore = sharedParsedIndexStores.computeIfAbsent( - QrcLyricUtils.cacheDirectory(appContext).absolutePath - ) { path -> - QrcParsedCacheIndexStore( + private val parsedIndexStore = synchronized(sharedParsedIndexStores) { + val path = QrcLyricUtils.cacheDirectory(appContext).absolutePath + sharedParsedIndexStores[path] ?: QrcParsedCacheIndexStore( cacheDirectory = File(path), logger = logger - ) + ).also { sharedParsedIndexStores[path] = it } } private val memoryCache = object : LinkedHashMap(MAX_MEMORY_CACHE, 0.75f, true) { diff --git a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcParsedCacheIndexStore.kt b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcParsedCacheIndexStore.kt index bb7199f..9d61992 100644 --- a/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcParsedCacheIndexStore.kt +++ b/PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcParsedCacheIndexStore.kt @@ -4,8 +4,7 @@ import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.FileOutputStream -import java.nio.file.Files -import java.nio.file.StandardCopyOption +import java.io.IOException import java.util.LinkedHashMap import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture @@ -17,6 +16,7 @@ class QrcParsedCacheIndexStore( private val logger: (String) -> Unit ) { private val lock = Any() + private val writeLock = Any() private val entries = LinkedHashMap() private val executor = Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "QrcParsedIndexWriteThread").apply { @@ -111,8 +111,9 @@ class QrcParsedCacheIndexStore( ) } - private fun writeSnapshot() { + private fun writeSnapshot() = synchronized(writeLock) { val snapshot = synchronized(lock) { entries.values.toList() } + var temp: File? = null try { cacheDirectory.mkdirs() val value = JSONObject() @@ -125,28 +126,19 @@ class QrcParsedCacheIndexStore( } ) .toString() - val temp = File(cacheDirectory, ".$INDEX_FILE_NAME.${System.nanoTime()}.tmp") + temp = File(cacheDirectory, ".$INDEX_FILE_NAME.${System.nanoTime()}.tmp") FileOutputStream(temp).use { output -> output.write(value.toByteArray(Charsets.UTF_8)) output.fd.sync() } - runCatching { - Files.move( - temp.toPath(), - indexFile.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - }.recoverCatching { - Files.move( - temp.toPath(), - indexFile.toPath(), - StandardCopyOption.REPLACE_EXISTING - ) - }.getOrThrow() + // Android's same-directory rename atomically replaces the file and + // is available on API 23. Never delete the previous index first. + if (!temp.renameTo(indexFile)) throw IOException("Index rename failed") logger("[QrcParsedIndex] saved entries=${snapshot.size}") } catch (exception: Exception) { logger("[QrcParsedIndex] save failed error=${exception.message}") + } finally { + temp?.delete() } } diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/CurrentLyricProbeTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/CurrentLyricProbeTest.kt new file mode 100644 index 0000000..7535593 --- /dev/null +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/CurrentLyricProbeTest.kt @@ -0,0 +1,18 @@ +package com.example.playeragent.media + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CurrentLyricProbeTest { + @Test + fun hanDetectionUsesLegacyUnicodeBlocks() { + assertTrue(CurrentLyricProbe.containsChinese("歌词 test")) + assertTrue(CurrentLyricProbe.containsChinese("\u3400")) + assertTrue(CurrentLyricProbe.containsChinese("\uF900")) + assertTrue(CurrentLyricProbe.containsChinese("〇")) + assertFalse(CurrentLyricProbe.containsChinese("English lyrics")) + assertFalse(CurrentLyricProbe.containsChinese("かな 😀")) + assertFalse(CurrentLyricProbe.containsChinese("")) + } +} diff --git a/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcParsedCacheIndexStoreTest.kt b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcParsedCacheIndexStoreTest.kt index 01dd854..75b3461 100644 --- a/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcParsedCacheIndexStoreTest.kt +++ b/PlayerAgentApp/src/test/java/com/example/playeragent/media/QrcParsedCacheIndexStoreTest.kt @@ -55,6 +55,34 @@ class QrcParsedCacheIndexStoreTest { recovered.close() } + @Test + fun failedRenameRetainsDestinationCleansTemporaryFileAndCanRetry() { + val directory = temporaryFolder.newFolder("blocked-index") + val destination = File(directory, QrcParsedCacheIndexStore.INDEX_FILE_NAME) + destination.mkdir() + val retained = File(destination, "retained.txt").apply { writeText("retain") } + val logs = mutableListOf() + val store = QrcParsedCacheIndexStore(directory) { logs += it } + try { + store.upsert(entry("song-a", "a.json", 30)) + store.flushNow() + assertEquals("retain", retained.readText()) + assertTrue(logs.any { it.contains("save failed") }) + assertFalse(directory.listFiles().orEmpty().any { it.name.endsWith(".tmp") }) + destination.deleteRecursively() + store.flushNow() + assertTrue(destination.isFile) + val restored = QrcParsedCacheIndexStore(directory) { } + try { + assertEquals(listOf("song-a"), restored.snapshot().map { it.songKey }) + } finally { + restored.close() + } + } finally { + store.close() + } + } + private fun entry(songKey: String, fileName: String, lines: Int) = QrcParsedCacheIndexStore.Entry( songKey = songKey, diff --git a/docs/ALBUM_ART_ARCHITECTURE.md b/docs/ALBUM_ART_ARCHITECTURE.md index 24a6a60..ec92f40 100644 --- a/docs/ALBUM_ART_ARCHITECTURE.md +++ b/docs/ALBUM_ART_ARCHITECTURE.md @@ -43,6 +43,7 @@ 12. V4 第三阶段的预测模型可以记录“已有精确 artwork cache”这一 readiness,但当前 QQ 音乐不暴露高置信下一/上一首 identity,因此没有执行候选图片预编码、跨端 Preview/HQ 预取或猜测式图片晋升。正式封面仍只按当前 trackId/generation 发布。 13. TrackInfo、PlaybackState、Preview/HQ A1 和 CurrentWord 使用同一个经过 identity 复核的 wire generation;Sony 捕获一次传输 generation 后贯穿 Start/Chunk/End,迟到旧 generation 不能覆盖 iOS 或 Sony PlayerAgent 当前封面。 14. Android notify callback 不能等同 L2CAP 已排空。封面 binary 使用 15ms 最小 pacing;command write 到达时同步预留 response quiet window,避免频繁切歌与 HQ 并发时写响应被 L2CAP hold queue 挤掉。该节流不作用于 JSON、歌词或 CurrentWord。 +15. iOS 在 inactive/background 收到当前歌曲 offer 后可请求 preview,命令队列跳过冻结的 HQ/同步请求并移除旧 artworkId 请求。offer、缓存命中、缩略图写入及 ActivityKit 发布使用有限后台任务,完成或系统到期结束,每个任务最多 15 秒;HQ 等重任务继续等待前台。共享缩略图原子写入并使用首次解锁后可读的文件保护,ContentState 仍只传 key/revision。此链路依赖系统提供 BLE 事件执行机会,后台调度及锁屏显示的本次验证边界见 [CONTROL_INTERACTION_AUDIT.md](CONTROL_INTERACTION_AUDIT.md)。 ## 关键状态 diff --git a/docs/CONTROL_INTERACTION_AUDIT.md b/docs/CONTROL_INTERACTION_AUDIT.md new file mode 100644 index 0000000..4707312 --- /dev/null +++ b/docs/CONTROL_INTERACTION_AUDIT.md @@ -0,0 +1,123 @@ +# iOS 滑杆、后台封面修复与全仓代码审计 + +审计日期:2026-10-01。审计与修复基线为 `acef77b`;提交和合并状态以关联 GitHub PR 为准。 + +iOS 音量无法拖动的原因已在模拟器中复现并修复;共用该控件的播放进度也一起修复。喇叭图标保持展示用途,直接拖动音量滑杆即可调节。随后按用户要求修复了后台封面更新及此前审计发现的四类问题,并处理 Sony lint 的全部 20 个错误。修复和自动化通过不能据此宣称整体代码没有缺陷,实机联动验收仍未执行。 + +## 滑杆根因与修复 + +旧版 [ContentView.swift](../IOSBleFeasibility/IOSBleFeasibility/ContentView.swift) 中的 `CompactPlayerSlider` 单独绘制轨道与滑块,再将实际接收触摸的 SwiftUI `Slider` 设置为 `.opacity(0.001)`。模拟器中的 `UIHostingController`、`UIWindow.hitTest` 测试确认实际控件有效透明度为 `0.001`,轨道中心没有命中该控件。修复前该回归测试有两项断言失败。 + +现在由同一个可见的原生 `UISlider` 绘制并处理交互,通过 `UIViewRepresentable` 接入原有绑定。保留细轨道和小滑块外观,将实际触摸高度扩大到 44pt。音量仍按整数档位调整,播放进度仍连续调整;拖动期间保留本地编辑值,沿用原有拖动更新与松手最终提交,禁用时拒绝修改。VoiceOver 调整也完成一次完整的开始与结束回调。 + +BLE UUID、命令名、歌词和封面 payload、A1/A2 header 均未修改;后台命令调度增加下文说明的当前 preview 例外。 + +## 后台封面空白的根因与修复 + +证据:[BLETestManager.swift](../IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift) 的 `flushCommandWriteQueue`、`albumArtSendCommand` 和 `publishLiveArtworkIfCurrent`。原先命令队列在非 active 状态冻结全部非控制请求,包含 `ALBUM_ART_REQUEST quality=preview`。后台切歌会清理旧歌曲的灵动岛封面,新歌曲即使收到 offer,也无法发出拉图请求;恢复前台才有机会取到新图。这是源码确认的调度缺口,没有用实机后台切歌进行复现。 + +现在后台可发送当前 artworkId 的 preview,请求从队列中寻找可执行项,避免被冻结的历史或 HQ 请求挡住;切歌后的旧 artworkId 请求会被移除。HQ、历史、诊断和周期同步仍等待前台。BLE offer 到缩略图编码、共享文件写入与 ActivityKit 更新之间使用有限后台任务,各任务最多 15 秒;成功发布、回到前台、断线及系统/本地期限到达均会结束相应任务。已有缓存命中同样经过这条发布链路。 + +共享 JPEG 使用原子写入和 `completeFileProtectionUntilFirstUserAuthentication`,支持设备首次解锁后在锁屏期间读取。保留 80×80 缩略图、20KB 文件上限、key/revision 和歌曲身份校验,迟到任务不能覆盖新歌曲;图片正文不进入 Live Activity ContentState。 + +自动化覆盖当前 preview 放行、旧图及 HQ/历史/诊断冻结、绕过冻结队首、共享 JPEG 可解码、文件预算、失败写入保留上一个版本及清理。真实后台唤醒、锁屏文件权限和灵动岛显示未实机验证。后台更新仍取决于 iOS 提供的 BLE 事件执行机会,有限任务不是常驻运行保证,见 [Apple Core Bluetooth 后台说明](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html) 和 [UIKit 有限后台任务说明](https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time)。 + +## 同批修复的交互问题 + +| 问题 | 修复 | 验证依据 | +|---|---|---| +| 音量和进度共用的透明滑杆无法命中 | 使用可见原生控件,触摸高度 44pt | 修复前失败、修复后通过的控件命中测试 | +| VoiceOver 等非触摸调节需要结束编辑并提交 | 非触摸修改完成一次编辑生命周期 | 原生无障碍调节回归测试 | +| 设备详情或全屏歌词退出时立即打开诊断,存在弹窗切换竞争 | 记录目标,在原弹窗 `onDismiss` 后打开诊断 | 源码检查及完整 iOS 编译;未执行弹窗端到端测试 | +| 更多菜单、歌词关闭、回到当前行、查看原因和歌词行触摸区域偏小 | 相关触摸区域至少 44pt,关闭按钮保留原图形尺寸 | 布局源码检查及完整 iOS 编译 | +| 全屏歌词上一首和下一首缺少明确无障碍名称 | 增加本地化无障碍标签 | 源码检查及完整 iOS 编译 | + +新增回归测试位于 [PerformanceStabilityTests.swift](../IOSBleFeasibility/IOSBleFeasibilityTests/PerformanceStabilityTests.swift):控件命中、连续拖动只结束一次编辑、无障碍调节提交,以及禁用时不更新值或开启编辑。音量档位和连续进度两种配置均有交互回调覆盖;这些测试没有连接 Sony 或验证远端实际音量。 + +## 审计覆盖范围 + +| 范围 | 本次检查 | +|---|---| +| iOS 播放界面 | 主界面两种布局、播放与音量绑定、歌词页、菜单、设置、历史与诊断入口;透明度、禁用状态、动作绑定和弹窗生命周期 | +| iOS 状态与传输 | Observation stores、连接健康与重连、控制队列、音量合并、进度编辑、历史分包与请求收尾、歌词诊断期限、后台 preview 和共享缩略图、Live Activity 控制边界 | +| Sony 端 | 页面导航和动作注册、媒体命令、音量诊断、QRC 缓存与 API 兼容、GATT 与权限 lint;全部现有 JVM 单测和 Debug 构建 | +| Android Controller | 进度与音量 Compose 状态、动作和禁用条件、诊断请求;全部现有 JVM 单测、Debug 构建和 lint | +| 工具与配置 | smoke 报告 Python 测试、42 个 shell 脚本语法、构建与 manifest 检查、改动和既有验收边界 | + +结构发现使用重新建立并在修复后刷新的完整代码图谱,调用关系结合真实源文件和 `rg` 核对。本次没有逐项执行全部产品验收场景;系统回调、蓝牙时序和跨端并发也不能仅靠图谱或单测证明正确。 + +## 此前审计问题的修复结果 + +P1 表示功能可能持续不可用或在声明支持的平台上崩溃;P2 表示局部交互或诊断状态异常。以下四项均已修复。 + +### P1 历史同步在缺包或断线后无法重试 + +证据:[BLETestManager.swift](../IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift) 的 `syncPlaybackHistory`、`loadMorePlaybackHistory`、`handleHistoryPayloadEnd` 和 `clearConnectionTransports`;[PlaybackHistoryView.swift](../IOSBleFeasibility/IOSBleFeasibility/PlaybackHistoryView.swift) 的刷新禁用条件。 + +原先同步和分页设置忙碌状态,缺包、长度不符或 JSON 解码失败只记录日志并返回;断线没有重置历史传输和统计队列,也没有响应完成期限,刷新入口可能一直禁用。 + +现在发送结果、无效 start/chunk、缺包、长度/JSON/envelope 错误、响应类型不符和结构化命令错误统一进入失败收尾。每个历史及统计请求有 30 秒完成期限,成功或失败都会取消期限并清理请求;失败的统计项继续下一项。断线及清空本地缓存重置传输 token、队列和忙碌状态,旧连接的异步解码/合并回调不能改写新连接的界面。同步游标不推进时停止循环并允许重试。回归覆盖缺包、坏 JSON、发送失败、超时、断线、旧响应隔离和统计队列恢复,以及正常分页、游标持久化、统计完成、加载中去重和完成后再次加载;不包含实机丢包注入。 + +### P1 Sony 使用了高于声明最低版本的 Java API + +证据:[PlayerAgentApp/build.gradle](../PlayerAgentApp/build.gradle) 声明 `minSdk 23`,未开启 core library desugaring;修复前 lint 列出 15 个 `NewApi` 错误。 + +- [CurrentLyricProbe.kt](../PlayerAgentApp/src/main/java/com/example/playeragent/media/CurrentLyricProbe.kt):将 API 24 的 `Character.UnicodeScript` 改为兼容的 UnicodeBlock 汉字识别。 +- [QrcLyricCacheManager.kt](../PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcLyricCacheManager.kt):将 API 24 的 `ConcurrentHashMap.computeIfAbsent` 改为同步的共享实例初始化。 +- [QrcParsedCacheIndexStore.kt](../PlayerAgentApp/src/main/java/com/example/playeragent/media/QrcParsedCacheIndexStore.kt):移除 API 26 的 java.nio 文件移动;写入先 sync,再在同一目录 rename,失败保留旧索引并清理临时文件。写入串行化,避免 debounce 与 flush 相互覆盖。 + +新增汉字识别及索引 rename 失败后恢复的 JVM 回归,既有索引持久化测试继续通过。另修复 3 个 `WrongConstant`(API 33 同步返回值使用 BluetoothStatusCodes.SUCCESS,异步 GATT 回调仍使用 GATT_SUCCESS)、1 个 `CoarseFineLocation`(旧系统 coarse/fine 权限配对)和 1 个 `UnspecifiedRegisterReceiverFlag`(ContextCompat 显式 NOT_EXPORTED)。保持 minSdk 23,没有新增 lint suppress 或降低门槛;没有在 Android 6/7 设备运行。 + +### P2 歌词诊断请求未发出也会一直等待 + +证据:[BLETestManager.swift](../IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift) 的 `requestLyricDiagnostic`、`sendCommand`、`handlePeripheralDisconnect`,以及 [LyricDiagnosticView.swift](../IOSBleFeasibility/IOSBleFeasibility/LyricDiagnosticView.swift) 的进入与刷新动作。 + +原先请求只检查缓存曲目 ID,设置 loading 后即使 `sendCommand` 没有发出,也无法收尾;没有响应期限,断线或无响应时可能一直等待。 + +现在请求先检查当前曲目、连接和前台状态,再检查发送入队结果;发送失败、Sony unavailable/结构化错误、10 秒响应超时和断线都会结束 loading 并显示本地化可重试原因。新请求取消旧期限,切歌取消旧等待,成功取消期限。回归覆盖发送失败、离线缓存曲目刷新、旧期限隔离、成功及超时;没有执行诊断页面实机端到端测试。 + +### P2 旧 Android Controller 的音量回包可能覆盖拖动值 + +证据:[ControllerAppUi.kt](../ControllerApp/src/main/java/com/example/controllerapp/ui/ControllerAppUi.kt) 的 `VolumeControl`。 + +原先 `remember(playback.volumeCurrent, playback.volumeMax)` 会在旧回包到达时重建拖动值,使滑杆跳回或松手提交旧档位。 + +按用户追加授权修复旧兼容模块。独立的 `VolumeSliderState` 在拖动时保留本地值,90ms 更新和松手提交都读取该值;松手后恢复远端状态,禁用/断线取消编辑,范围变化约束到合法档位。新增 JVM 回归覆盖旧回包、最终提交、范围缩小和取消编辑;没有执行 Compose UI 实机测试。 + +## 自动化验证结果 + +| 检查 | 结果 | +|---|---| +| 修复前的滑杆命中回归 | 预期失败:1 个测试,2 项断言失败 | +| iOS 全部 XCTest | PASS,67 项,0 失败;iPhone 16 Pro 模拟器,iOS 18.3 | +| Sony JVM 单测与 Debug 构建 | PASS,137 项测试,0 失败 | +| Android Controller JVM 单测与 Debug 构建 | PASS,34 项测试,0 失败 | +| Android Controller lint | PASS,0 errors / 30 warnings | +| Sony lint | PASS,0 errors / 34 warnings;修复前 20 errors / 33 warnings | +| smoke 报告 Python 单测 | PASS,49 项 | +| shell 语法 | PASS,42 个脚本通过 `bash -n` | +| 差异格式与既有改动保护 | `git diff --check` PASS;用户已有部署脚本和 InfoPlist 改动保持原内容,String Catalog 原条目保持原值,仅追加两个诊断提示 | + +Sony lint 的 20 个错误均已消除。仍有 34 个 warning,包含原有 33 个建议及显式引入与 Controller 一致的 androidx.core 1.13.1 后新增的依赖版本建议;没有为消除 warning 进行全仓依赖升级。既有 V4 验收记录是历史基线,见 [V4_ACCEPTANCE_STATUS.md](V4_ACCEPTANCE_STATUS.md),本报告不改写此前的实机或性能验收结论。 + +验证命令: + +```bash +xcodebuild -project IOSBleFeasibility/IOSBleFeasibility.xcodeproj \ + -scheme sonyMusic -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.3' \ + -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO test + +bash gradlew :PlayerAgentApp:testDebugUnitTest :PlayerAgentApp:assembleDebug \ + :PlayerAgentApp:lintDebug :ControllerApp:testDebugUnitTest \ + :ControllerApp:assembleDebug :ControllerApp:lintDebug +python3 -m unittest discover -s tools/smoke/tests -v +git diff --check +``` + +Android 验证使用本机缓存的 JDK 17。上述 Xcode 命令用模拟器名称表达,实际执行时以同一模拟器的 ID 定位并将 DerivedData 与结果包放在仓库外。 + +初次滑杆修复和工具验证保存在 `/tmp/musicble-control-audit-20261001/`,包括修复前的 `BeforeFix.xcresult`、修复后的 `InteractionAudit.xcresult` 和 `python-tests.log`。后台封面及四项审计修复的最终验证保存在 `/tmp/musicble-background-artwork-20261001/`,包括 `BackgroundAndAuditFinal2.xcresult`、`ios-tests-final2.log` 和 `android-checks.log`。最终 67 项通过结果替代了前两轮受测试蓝牙状态回调干扰的结果,以及新增成功路径测试误读私有字段导致的中间编译失败;该测试改为验证公开操作和状态,没有放宽生产代码访问权限。Python 和 shell 工具本轮没有继续修改,沿用同一工作区首轮通过结果。临时产物不提交到仓库。 + +GitHub 提交前又在独立修复 worktree 中验证了只含本次改动的代码:iOS 67、Sony 137、Android Controller 34 项测试均通过,两端 Android Debug 构建和 lint 通过;本地化只包含本次新增的两个诊断提示。结果保存在 `/tmp/musicble-background-artwork-20261001/publish/IsolatedCommit.xcresult`、`publish/ios-tests.log` 和 `publish/android-checks.log`。GitHub PR 和合并后的 CI 是独立的交付检查。 + +按用户要求没有实机演示、安装或跨设备 smoke;实机 quick/full smoke 均未执行,模拟器测试不替代 iPhone 与 Sony 的实际联动验收。GitHub 交付只包含本次修复、回归测试、文档及两个新增诊断提示,用户已有的部署脚本、InfoPlist 和其他本地化改动保留在原工作区。 diff --git a/docs/IOS_ARCHITECTURE.md b/docs/IOS_ARCHITECTURE.md index e908b7d..2b60651 100644 --- a/docs/IOS_ARCHITECTURE.md +++ b/docs/IOS_ARCHITECTURE.md @@ -69,6 +69,10 @@ CoreBluetooth 使用 `com.musicblecontroller.sony.central.v1` 做状态恢复, 用户控制与播放状态 fallback 共用串行 command write 队列,但普通 `GET_PLAYBACK_STATE` 会被更新的 fallback 或新的用户控制合并;前台恢复验证与 Health probe 的请求序列受保护。NEXT/PREVIOUS fallback 等待 1 秒,优先让 Sony 的正式 TrackInfo/PlaybackState 主动推送完成;新 trackId 一旦被接受即取消剩余 fallback。该策略不新增 Timer、不重发控制命令,也不改变 BLE 协议。 +后台队列允许用户控制和当前 artworkId 的 preview,请求可越过冻结的 HQ/历史/诊断项;旧封面请求会被清理。封面 offer 到 App Group 缩略图及 ActivityKit 发布使用有限后台任务,最多 15 秒或系统提前到期;不增加后台轮询。缩略图使用原子写入及首次解锁后可读的文件保护,完整边界见 [ALBUM_ART_ARCHITECTURE.md](ALBUM_ART_ARCHITECTURE.md)。 + +`sendCommand` 返回能否入队,业务请求继续等待 Sony 响应。历史/统计请求有 30 秒完成期限,歌词诊断有 10 秒期限;发送失败、错误响应、缺包、解析失败、超时和断线清理请求及忙碌状态。断线或清空历史缓存使历史 transfer token 失效,旧异步回调不能写回新连接的界面;统计失败继续下一项,切歌或新的诊断请求取消旧诊断期限。 + ## 关键状态 - 连接:`connectionStatus`、`connectionDisplayState`、`connectionHealthState`、`autoReconnectState`。