From 07d41115cd00c925b3f272ef6a740b8942168313 Mon Sep 17 00:00:00 2001 From: sqz Date: Tue, 6 Oct 2026 08:23:23 +0800 Subject: [PATCH 1/3] Fix lyrics control outcomes and activity publication lifecycle --- .../BLEObservableStateModels.swift | 5 + .../IOSBleFeasibility/BLETestManager.swift | 127 ++++++- .../LiveActivityManager.swift | 72 ++-- .../IOSBleFeasibility/Localizable.xcstrings | 3 + .../LyricsPictureInPictureController.swift | 10 +- .../LyricsPictureInPictureSourceView.swift | 7 + .../LyricsPresentationSnapshot.swift | 147 +++++++- .../LyricsSampleBufferRenderer.swift | 10 + .../LyricsPresentationTests.swift | 325 ++++++++++++++++++ docs/LYRICS_DISPLAY_IMPLEMENTATION.md | 60 ++++ 10 files changed, 708 insertions(+), 58 deletions(-) diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift index 1a5126e..5c689c7 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLEObservableStateModels.swift @@ -203,6 +203,11 @@ final class PlaybackStore { final class LyricsStore { private(set) var live = BLELiveLyricState() private(set) var document = BLEFullLyricsViewState() + private(set) var floatingPlaybackControlState: LyricsPlaybackControlState = .idle + + func updateFloatingPlaybackControlState(_ value: LyricsPlaybackControlState) { + if floatingPlaybackControlState != value { floatingPlaybackControlState = value } + } func updateLive(_ value: BLELiveLyricState) { if live != value { live = value } diff --git a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift index 9128744..dd927b0 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/BLETestManager.swift @@ -468,6 +468,7 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } @Published private(set) var negotiatedV3Features: BLEProtocolV3Features = [] @Published private(set) var serverSessionId = "-" + private var capabilitiesServerSessionID = "-" @Published private(set) var lastServerEventSequence: UInt64 = 0 @Published private(set) var lastCommandErrorSummary = "-" @@ -560,11 +561,14 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { private var lastLyricsPresentationKey: LyricsPresentationSnapshot.Key? private var lyricsConfirmedTrackID = "" private var lyricsConfirmedGeneration: Int64 = 0 + private var lyricsConfirmedServerSessionID = "-" private var lyricsConfirmedUptime: TimeInterval = 0 private var lyricsConfirmedWasPlaying = false private var lyricsPlaybackConfirmationRevision: UInt64 = 0 private var floatingPlaybackTargetPolicy = LyricsPlaybackTargetPolicy() private var floatingPlaybackReconciliationTask: Task? + private var floatingPlaybackResolutionTask: Task? + private var connectionTransportEpoch: UInt64 = 0 private let liveActivityPublisher: LiveActivityManager? private let lyricsPresentationUptime: () -> TimeInterval private let floatingPlaybackCommandSender: (@MainActor (LiveActivityControlCommand, UInt64, Date) -> LiveActivityControlResult)? @@ -821,12 +825,14 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { liveActivityPublisher: LiveActivityManager? = nil, lyricsPresentationUptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, remoteClock: MonotonicClockSynchronizer = MonotonicClockSynchronizer(), - floatingPlaybackCommandSender: (@MainActor (LiveActivityControlCommand, UInt64, Date) -> LiveActivityControlResult)? = nil + floatingPlaybackCommandSender: (@MainActor (LiveActivityControlCommand, UInt64, Date) -> LiveActivityControlResult)? = nil, + floatingPlaybackConfirmationTimeout: TimeInterval = 8 ) { self.liveActivityPublisher = liveActivityPublisher self.lyricsPresentationUptime = lyricsPresentationUptime self.clockSynchronizer = remoteClock self.floatingPlaybackCommandSender = floatingPlaybackCommandSender + self.floatingPlaybackTargetPolicy = LyricsPlaybackTargetPolicy(confirmationTimeout: floatingPlaybackConfirmationTimeout) super.init() syncAllStores() syncPreferencesStateFromStore() @@ -2265,7 +2271,9 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { let now = lyricsPresentationUptime() let deadline = lyricsConfirmedUptime + (lyricsConfirmedWasPlaying ? 45 : 300) let confirmed = lyricsConfirmedTrackID == currentTrackID && - lyricsConfirmedGeneration == currentTrackGeneration && lyricsConfirmedUptime > 0 + lyricsConfirmedGeneration == currentTrackGeneration && + lyricsConfirmedServerSessionID == serverSessionId && lyricsConfirmedUptime > 0 && + (capabilitiesServerSessionID == "-" || serverSessionId == capabilitiesServerSessionID) let fresh = confirmed && now < deadline var position = displayPositionMs if fresh, isPlaying, !isSeeking, durationMs > 0 { @@ -2314,37 +2322,59 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { trackID: currentTrackID, trackGeneration: currentTrackGeneration, revision: lyricsPresentationRevision, timelineRevision: lyricsTimelineRevision, lineIndex: lineIndex, text: text, title: title, artist: artist, isPlaying: isPlaying, - status: status, validUntilUptime: deadline + status: status, validUntilUptime: deadline, + playbackControlState: lyricsStore.floatingPlaybackControlState ) } private func confirmLyricsPresentationClock() { lyricsConfirmedTrackID = currentTrackID lyricsConfirmedGeneration = currentTrackGeneration + lyricsConfirmedServerSessionID = serverSessionId lyricsConfirmedUptime = lyricsPresentationUptime() lyricsConfirmedWasPlaying = isPlaying } @MainActor func requestFloatingLyricsPlayback(_ target: Bool) -> Bool { + floatingPlaybackTargetPolicy.expire(now: lyricsPresentationUptime()) + syncFloatingPlaybackControlState() let snapshot = makeLyricsPresentationSnapshot() guard !isSeeking, snapshot.hasAuthoritativePlayback, - lyricsPresentationUptime() < snapshot.validUntilUptime else { return false } - guard floatingPlaybackTargetPolicy.request( + lyricsPresentationUptime() < snapshot.validUntilUptime else { + if floatingPlaybackTargetPolicy.controlState != .unknown { + lyricsStore.updateFloatingPlaybackControlState(.unavailable) + } + return false + } + let reserved = floatingPlaybackTargetPolicy.request( target: target, snapshot: snapshot, - confirmationRevision: lyricsPlaybackConfirmationRevision, now: lyricsPresentationUptime() - ) else { return true } + confirmationRevision: lyricsPlaybackConfirmationRevision, now: lyricsPresentationUptime(), + connectionEpoch: connectionTransportEpoch, serverSessionID: serverSessionId + ) + syncFloatingPlaybackControlState() + // true means an accepted intent or an already confirmed target, never + // media execution. The observable state explicitly shows pending/unknown. + guard reserved else { + return floatingPlaybackTargetPolicy.controlState == .idle || + floatingPlaybackTargetPolicy.controlState == .pending + } return sendFloatingLyricsPlaybackCommand(allowDeferredRetry: true) } @MainActor private func reconcileFloatingLyricsPlayback(allowDeferredRetry: Bool = true) { + floatingPlaybackTargetPolicy.expire(now: lyricsPresentationUptime()) + syncFloatingPlaybackControlState() guard !isSeeking else { return } let snapshot = makeLyricsPresentationSnapshot() - guard floatingPlaybackTargetPolicy.reconcile( + let reserved = floatingPlaybackTargetPolicy.reconcile( snapshot: snapshot, confirmationRevision: lyricsPlaybackConfirmationRevision, - now: lyricsPresentationUptime() - ) else { return } + now: lyricsPresentationUptime(), connectionEpoch: connectionTransportEpoch, + serverSessionID: serverSessionId + ) + syncFloatingPlaybackControlState() + guard reserved else { return } _ = sendFloatingLyricsPlaybackCommand(allowDeferredRetry: allowDeferredRetry) } @@ -2354,7 +2384,12 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { let issuedAt = Date() let result = floatingPlaybackCommandSender?(.playPause, sequence, issuedAt) ?? sendLiveActivityCommand(.playPause, seq: sequence, issuedAt: issuedAt) - guard result != .sent else { return true } + if result == .sent { + floatingPlaybackTargetPolicy.commandSent(sequence: sequence, now: lyricsPresentationUptime()) + syncFloatingPlaybackControlState() + scheduleFloatingPlaybackResolution() + return true + } floatingPlaybackTargetPolicy.commandNotSent() // The next toggle was not sent. Respect the existing transport gate, then // recheck current authority and the last intent once, without blind retries. @@ -2372,7 +2407,8 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } return true } - if !transportBusy { floatingPlaybackTargetPolicy.cancelDesired() } + floatingPlaybackTargetPolicy.commandSendFailed() + syncFloatingPlaybackControlState() log("[Lyrics-PiP] playback target command not sent result=\(result)") return false } @@ -2381,6 +2417,34 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { floatingPlaybackReconciliationTask?.cancel() floatingPlaybackReconciliationTask = nil floatingPlaybackTargetPolicy.cancelDesired() + syncFloatingPlaybackControlState() + } + + private func syncFloatingPlaybackControlState() { + lyricsStore.updateFloatingPlaybackControlState(floatingPlaybackTargetPolicy.controlState) + if floatingPlaybackTargetPolicy.takeStateQuery() { + // One existing state request per recovery transition, never a toggle + // retry or a polling loop. An unchanged report cannot release it. + sendGetPlaybackState() + } + if floatingPlaybackTargetPolicy.receipt == nil { + floatingPlaybackResolutionTask?.cancel() + floatingPlaybackResolutionTask = nil + } + } + + @MainActor + private func scheduleFloatingPlaybackResolution() { + floatingPlaybackResolutionTask?.cancel() + guard let receipt = floatingPlaybackTargetPolicy.receipt else { return } + let delay = floatingPlaybackTargetPolicy.confirmationTimeout + floatingPlaybackResolutionTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled, let self, + self.floatingPlaybackTargetPolicy.receipt == receipt else { return } + self.floatingPlaybackResolutionTask = nil + self.reconcileFloatingLyricsPlayback(allowDeferredRetry: false) + } } func copyIOSLogs() { @@ -4781,10 +4845,13 @@ final class BLETestManager: NSObject, ObservableObject, @unchecked Sendable { } private func clearConnectionTransports(reason: String) { + connectionTransportEpoch &+= 1 + capabilitiesServerSessionID = "-" lyricsConfirmedTrackID = "" lyricsConfirmedUptime = 0 cancelFloatingLyricsPlaybackIntent() floatingPlaybackTargetPolicy.clear() + syncFloatingPlaybackControlState() log("[BLE-Reconnect] clear characteristics reason=\(reason)") resetHistoryRequests(reason: reason) finishLyricDiagnosticRequest(error: lyricDiagnosticLoading ? "诊断暂不可用,请确认连接后重试" : nil) @@ -5511,17 +5578,21 @@ extension BLETestManager: LiveActivityBLECommandSending { liveActivityControlInFlightSeq = seq liveActivityControlWriteStartedAtMs = nowMs ctrlLog("[LA-CTRL] write requested seq=\(seq) cmd=\(command.rawValue)") - sendCommand( + let queued = sendCommand( cmd: command.rawValue, extra: ["source": "liveActivity"], seq: seq ) + if !queued { + liveActivityControlInFlightSeq = nil + liveActivityControlWriteStartedAtMs = 0 + } return recordLiveActivityControlResult( command: command, seq: seq, - result: .sent, + result: queued ? .sent : .failed, startedAtMs: startedAtMs, - inFlight: true + inFlight: queued ) } } @@ -6059,6 +6130,7 @@ extension BLETestManager: CBPeripheralDelegate { serverSupportsTransferRetry = false negotiatedV3Features = [] serverSessionId = "-" + capabilitiesServerSessionID = "-" lastServerEventSequence = 0 eventSequenceDiagnostics.reset() lastMediaLoadStateKeyByResource.removeAll() @@ -6175,12 +6247,14 @@ extension BLETestManager: CBPeripheralDelegate { ) } let peripheralID = peripheral.identifier + let transportEpoch = connectionTransportEpoch inboundPipeline.submit { [weak self] in guard let self else { return } if data.first == 0xA1 { DispatchQueue.main.async { [weak self] in guard let self, - self.sonyPeripheral?.identifier == peripheralID else { return } + self.sonyPeripheral?.identifier == peripheralID, + self.connectionTransportEpoch == transportEpoch else { return } self.markStatusNotifyReceived(type: "albumArtBinaryChunk") self.albumArtReceiver.handleBinaryChunk(data) } @@ -6189,7 +6263,8 @@ extension BLETestManager: CBPeripheralDelegate { if data.first == 0xA2 { DispatchQueue.main.async { [weak self] in guard let self, - self.sonyPeripheral?.identifier == peripheralID else { return } + self.sonyPeripheral?.identifier == peripheralID, + self.connectionTransportEpoch == transportEpoch else { return } self.markStatusNotifyReceived(type: "fullLyricsBinaryChunk") self.handleFullLyricsBinaryChunk(data) } @@ -6234,7 +6309,8 @@ extension BLETestManager: CBPeripheralDelegate { ) DispatchQueue.main.async { [weak self] in guard let self, - self.sonyPeripheral?.identifier == peripheralID else { return } + self.sonyPeripheral?.identifier == peripheralID, + self.connectionTransportEpoch == transportEpoch else { return } guard let object = objectBox?.value, let type else { if let text { self.log("[BLE] status notify received: \(text)") @@ -6255,7 +6331,14 @@ extension BLETestManager: CBPeripheralDelegate { } private func parseStatus(_ object: [String: Any], type: String, recordTransportActivity: Bool = true) { + let transportEpoch = connectionTransportEpoch let apply: () -> Void = { + guard self.connectionTransportEpoch == transportEpoch else { return } + if type == "commandError", let sid = object["sid"] as? String, + self.capabilitiesServerSessionID != "-", sid != self.capabilitiesServerSessionID { + self.log("[Lyrics-PiP] commandError ignored from old server session") + return + } if recordTransportActivity { self.markStatusNotifyReceived(type: type) } self.observeV3StatusMetadata(object, type: type) switch type { @@ -6274,6 +6357,7 @@ extension BLETestManager: CBPeripheralDelegate { self.serverSupportsClockSyncV1 = ack.v2Features.contains(.clockSyncV1) self.serverSupportsTransferRetry = ack.v2Features.contains(.transferRetry) self.negotiatedV3Features = ack.v3Features.intersection(.all) + self.capabilitiesServerSessionID = ack.sessionId ?? "-" if let sessionId = ack.sessionId { self.serverSessionId = sessionId self.lastServerEventSequence = 0 @@ -6870,6 +6954,11 @@ extension BLETestManager: CBPeripheralDelegate { ? ",可重试\(payload.retryAfterMs.map { "(\($0)ms 后)" } ?? "")" : "" lastCommandErrorSummary = "\(correlatedCommand):\(payload.code)\(retryText)" + if floatingPlaybackTargetPolicy.receiveCommandError( + payload, connectionEpoch: connectionTransportEpoch, serverSessionID: serverSessionId + ) { + syncFloatingPlaybackControlState() + } if let sequence = payload.sequence { if let requestId = historyRequestSequences.first(where: { $0.value == sequence })?.key { failHistoryRequest(requestId, reason: payload.code) @@ -7241,6 +7330,7 @@ extension BLETestManager: CBPeripheralDelegate { lyricsConfirmedTrackID = "" cancelFloatingLyricsPlaybackIntent() floatingPlaybackTargetPolicy.clear() + syncFloatingPlaybackControlState() playbackStateRefreshWorkItem?.cancel() playbackStateRefreshWorkItem = nil resetCurrentWordFence() @@ -7609,6 +7699,7 @@ extension BLETestManager: CBPeripheralDelegate { private func updateLiveActivityDisconnected() { cancelFloatingLyricsPlaybackIntent() floatingPlaybackTargetPolicy.clear() + syncFloatingPlaybackControlState() if preferences.floatingLyricsEnabled { Task { @MainActor [weak self] in guard let self else { return } diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift index a288961..67d1df0 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift @@ -14,6 +14,8 @@ final class LiveActivityManager { private var activity: Activity? private var activityStateTask: Task? + private var publicationTask: Task? + private var endingActivityIDs: Set = [] private var latestState: SonyMusicActivityAttributes.ContentState? private var latestReason = "unknown" private var latestForce = false @@ -147,6 +149,8 @@ final class LiveActivityManager { func end(logger: ((String) -> Void)? = nil) { publicationEpoch &+= 1 + publicationTask?.cancel() + publicationTask = nil updateInFlight = false debounceTask?.cancel() debounceTask = nil @@ -161,10 +165,11 @@ final class LiveActivityManager { let state = lastSentState lastSentState = nil lastSentVersion = 0 - guard let activity else { return } - self.activity = nil activityStateTask?.cancel() activityStateTask = nil + guard let activity else { return } + self.activity = nil + endingActivityIDs.insert(activity.id) Task { if let state { await activity.end( @@ -174,6 +179,7 @@ final class LiveActivityManager { } else { await activity.end(nil, dismissalPolicy: .immediate) } + endingActivityIDs.remove(activity.id) logger?("[LiveActivity] end") } } @@ -399,16 +405,16 @@ final class LiveActivityManager { } updateInFlight = true let epoch = publicationEpoch - let backgroundTask = BoundedBackgroundTask(name: "Live Activity update") - - Task { [weak self] in + publicationTask = Task { [weak self] in + guard !Task.isCancelled, let self, self.publicationEpoch == epoch else { return } + let backgroundTask = BoundedBackgroundTask(name: "Live Activity update") defer { backgroundTask.end() } - guard let self else { return } let startedAt = Date() logger?("[LiveActivityPerf] update start reason=\(reason)") logger?("[LiveActivityState] update start version=\(version)") let published = await self.publish(state, epoch: epoch, logger: logger) - guard self.publicationEpoch == epoch else { return } + guard !Task.isCancelled, self.publicationEpoch == epoch else { return } + self.publicationTask = nil if published { self.lastSentState = state self.lastSentVersion = version @@ -442,36 +448,42 @@ final class LiveActivityManager { epoch: UInt64, logger: ((String) -> Void)? ) async -> Bool { + guard !Task.isCancelled, publicationEpoch == epoch else { return false } if let statePublisher { return await statePublisher(state) } - guard let target = await ensureActivity(for: state, logger: logger) else { return false } + guard let target = await ensureActivity(for: state, epoch: epoch, logger: logger), + !Task.isCancelled, publicationEpoch == epoch else { return false } await target.update(ActivityContent(state: state, staleDate: staleDate(for: state))) - guard publicationEpoch == epoch else { return false } + guard !Task.isCancelled, publicationEpoch == epoch else { return false } activity = target return true } private func ensureActivity( for state: SonyMusicActivityAttributes.ContentState, + epoch: UInt64, logger: ((String) -> Void)? ) async -> Activity? { + guard !Task.isCancelled, publicationEpoch == epoch else { return nil } if let activity { - endDuplicateActivities(keeping: activity.id, logger: logger) + endDuplicateActivities(keeping: activity.id, epoch: epoch, logger: logger) return activity } logger?("[LiveActivity] activity missing, lookup existing") - if let existing = restoreExistingActivity(logger: logger) { + if let existing = restoreExistingActivity(epoch: epoch, logger: logger) { return existing } - return await start(state: state, logger: logger) + return await start(state: state, epoch: epoch, logger: logger) } private func start( state: SonyMusicActivityAttributes.ContentState, + epoch: UInt64, logger: ((String) -> Void)? ) async -> Activity? { - if let existing = restoreExistingActivity(logger: logger) { + guard !Task.isCancelled, publicationEpoch == epoch else { return nil } + if let existing = restoreExistingActivity(epoch: epoch, logger: logger) { return existing } if startInFlight { @@ -494,7 +506,7 @@ final class LiveActivityManager { pushType: nil ) activity = requested - observeStateUpdates(for: requested, logger: logger) + observeStateUpdates(for: requested, epoch: epoch, logger: logger) logger?("[LiveActivity] start id=\(requested.id)") return requested } catch { @@ -505,25 +517,36 @@ final class LiveActivityManager { } private func restoreExistingActivity( + epoch: UInt64, logger: ((String) -> Void)? ) -> Activity? { + guard !Task.isCancelled, publicationEpoch == epoch else { return nil } let activities = Activity.activities - guard let existing = activities.first else { return nil } + guard let existing = activities.first(where: { + !endingActivityIDs.contains($0.id) && ($0.activityState == .active || $0.activityState == .stale) + }) else { return nil } activity = existing - observeStateUpdates(for: existing, logger: logger) - endDuplicateActivities(keeping: existing.id, logger: logger) + observeStateUpdates(for: existing, epoch: epoch, logger: logger) + endDuplicateActivities(keeping: existing.id, epoch: epoch, logger: logger) logger?("[LiveActivity] existing restored id=\(existing.id)") return existing } private func endDuplicateActivities( keeping activityID: String?, + epoch: UInt64, logger: ((String) -> Void)? ) { + guard !Task.isCancelled, publicationEpoch == epoch else { return } for duplicate in Activity.activities - where duplicate.id != activityID { + where duplicate.id != activityID && !endingActivityIDs.contains(duplicate.id) { logger?("[LiveActivity] duplicate activity ending id=\(duplicate.id)") - Task { + endingActivityIDs.insert(duplicate.id) + Task { [weak self] in + guard let self else { return } + defer { self.endingActivityIDs.remove(duplicate.id) } + guard !Task.isCancelled, self.publicationEpoch == epoch, + self.activity?.id != duplicate.id else { return } await duplicate.end(nil, dismissalPolicy: .immediate) } } @@ -531,20 +554,21 @@ final class LiveActivityManager { private func observeStateUpdates( for activity: Activity, + epoch: UInt64, logger: ((String) -> Void)? ) { activityStateTask?.cancel() activityStateTask = Task { [weak self] in for await state in activity.activityStateUpdates { await MainActor.run { + guard !Task.isCancelled, let self, self.publicationEpoch == epoch, + self.activity?.id == activity.id else { return } logger?("[LiveActivity] state changed state=\(state)") if state == .active || state == .stale { - self?.activity = activity + self.activity = activity } else if state == .ended || state == .dismissed { - if self?.activity?.id == activity.id { - self?.activity = nil - } - self?.startCooldownUntil = Date().addingTimeInterval(5) + self.activity = nil + self.startCooldownUntil = Date().addingTimeInterval(5) } } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings index 0570dbb..21d88d4 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings +++ b/IOSBleFeasibility/IOSBleFeasibility/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "zh-Hans", "strings" : { + "播放控制已发送,等待确认" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback control sent. Waiting for confirmation."}}}}, + "播放控制结果未知,请回到应用重新连接后重试" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback result unknown. Return to the app, reconnect, then try again."}}}}, + "播放控制未执行,状态同步后可重试" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Playback control was not executed. Sync the state, then try again."}}}}, "灵动岛显示歌词" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Show lyrics in Dynamic Island"}}}}, "在紧凑态优先显示当前歌词;长句会截断,多活动时由系统选择最小态。" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Prioritize lyrics in the compact view. Long lines are truncated; the system chooses the minimal view when multiple activities compete."}}}}, "悬浮歌词" : {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Floating lyrics"}}}}, diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureController.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureController.swift index 11a90e9..cb7f88e 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureController.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureController.swift @@ -386,10 +386,16 @@ extension LyricsPictureInPictureController: AVPictureInPictureSampleBufferPlayba Task { @MainActor [weak self] in guard let self, self.controller === pip, self.state == .active, self.lifecycle.enabled, self.playbackStatus.acceptsControls(owner: ObjectIdentifier(pip)) else { return } - if self.setPlaying?(playing) != true { - self.reason = AppLocalization.string("播放状态未同步,无法执行控制") + let accepted = self.setPlaying?(playing) == true + if !accepted { + // A known control failure/unknown result is displayed in the + // snapshot. Keep the fallback for an unavailable source only. + if self.snapshotProvider?().playbackControlState == .idle { + self.reason = AppLocalization.string("播放状态未同步,无法执行控制") + } pip.invalidatePlaybackState() } + if let snapshot = self.snapshotProvider?() { self.update(snapshot, force: true) } } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureSourceView.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureSourceView.swift index ff334bf..a8e562a 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureSourceView.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricsPictureInPictureSourceView.swift @@ -56,6 +56,13 @@ struct FloatingLyricsPanel: View { if !controller.reason.isEmpty { Text(controller.reason).font(.caption2).foregroundStyle(.secondary) } + let controlState = manager.lyricsStore.floatingPlaybackControlState + if controlState != .idle { + Text(controlState.message).font(.caption2).foregroundStyle(.secondary) + } + if controlState == .unknown { + Button("重新连接") { manager.forceReconnect() } + } } Spacer(minLength: 0) } diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricsPresentationSnapshot.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricsPresentationSnapshot.swift index 043659e..92c4142 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricsPresentationSnapshot.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricsPresentationSnapshot.swift @@ -42,6 +42,7 @@ struct LyricsPresentationSnapshot: Equatable, Sendable { let isPlaying: Bool let status: Status let validUntilUptime: TimeInterval + var playbackControlState: LyricsPlaybackControlState = .idle var key: Key { Key(trackID: trackID, trackGeneration: trackGeneration, @@ -144,47 +145,162 @@ struct LyricsPiPStateMachine { /// The wire protocol exposes toggle. Keep desired, confirmed and in-flight /// states separate: elapsed time alone cannot prove a toggle was not executed. +enum LyricsPlaybackControlState: Equatable, Sendable { + case idle, pending, unknown, failed, unavailable + + var message: String { + switch self { + case .idle: return "" + case .pending: return AppLocalization.string("播放控制已发送,等待确认") + case .unknown: return AppLocalization.string("播放控制结果未知,请回到应用重新连接后重试") + case .failed: return AppLocalization.string("播放控制未执行,状态同步后可重试") + case .unavailable: return AppLocalization.string("播放状态未同步,无法执行控制") + } + } +} + struct LyricsPlaybackTargetPolicy { - private var identity: (trackID: String, generation: Int64)? + struct CommandReceipt: Equatable { + let sequence: UInt64 + let trackID: String + let generation: Int64 + let connectionEpoch: UInt64 + let serverSessionID: String + let sentAt: TimeInterval + } + + private struct InFlight { + let target: Bool + let confirmationRevision: UInt64 + var receipt: CommandReceipt? + } + + let confirmationTimeout: TimeInterval + private var identity: (trackID: String, generation: Int64, connectionEpoch: UInt64, serverSessionID: String)? private var desired: Bool? + private var desiredExpiresAt: TimeInterval? private var confirmed: Bool? private var confirmedRevision: UInt64 = 0 - private var inFlight: (target: Bool, confirmationRevision: UInt64)? + private var inFlight: InFlight? + private var rejectedAtRevision: UInt64? + private var stateQueryNeeded = false + private(set) var controlState: LyricsPlaybackControlState = .idle + + var receipt: CommandReceipt? { inFlight?.receipt } + + init(confirmationTimeout: TimeInterval = 8) { + self.confirmationTimeout = confirmationTimeout + } mutating func request(target: Bool, snapshot: LyricsPresentationSnapshot, - confirmationRevision: UInt64, now: TimeInterval) -> Bool { + confirmationRevision: UInt64, now: TimeInterval, + connectionEpoch: UInt64 = 0, serverSessionID: String = "-") -> Bool { + expire(now: now) guard snapshot.hasAuthoritativePlayback, now < snapshot.validUntilUptime else { return false } - adoptIdentity(snapshot) - desired = target + adoptIdentity(snapshot, connectionEpoch: connectionEpoch, serverSessionID: serverSessionID) acceptConfirmation(snapshot, revision: confirmationRevision) + guard controlState != .unknown, rejectedAtRevision == nil else { return false } + desired = target + desiredExpiresAt = now + confirmationTimeout + controlState = .pending return reserveCommand() } mutating func reconcile(snapshot: LyricsPresentationSnapshot, - confirmationRevision: UInt64, now: TimeInterval) -> Bool { + confirmationRevision: UInt64, now: TimeInterval, + connectionEpoch: UInt64 = 0, serverSessionID: String = "-") -> Bool { + expire(now: now) guard snapshot.hasAuthoritativePlayback, now < snapshot.validUntilUptime else { return false } - adoptIdentity(snapshot) + adoptIdentity(snapshot, connectionEpoch: connectionEpoch, serverSessionID: serverSessionID) acceptConfirmation(snapshot, revision: confirmationRevision) return reserveCommand() } - /// Only a command rejected before sending may release the reservation. + mutating func commandSent(sequence: UInt64, now: TimeInterval) { + guard let identity, inFlight != nil else { return } + inFlight?.receipt = CommandReceipt( + sequence: sequence, trackID: identity.trackID, generation: identity.generation, + connectionEpoch: identity.connectionEpoch, serverSessionID: identity.serverSessionID, sentAt: now + ) + } + + /// A transport refusal before sending is safe to release. A timeout is not. mutating func commandNotSent() { inFlight = nil } - mutating func cancelDesired() { desired = nil } + mutating func commandSendFailed() { + commandNotSent() + cancelDesired() + controlState = .failed + } + + @discardableResult + mutating func receiveCommandError(_ payload: BLECommandErrorPayload, + connectionEpoch: UInt64, serverSessionID: String) -> Bool { + guard let receipt, payload.sequence == receipt.sequence, payload.command == "PLAY_PAUSE", + connectionEpoch == receipt.connectionEpoch, serverSessionID == receipt.serverSessionID, + payload.trackId == nil || payload.trackId == receipt.trackID, + payload.generation == nil || payload.generation == receipt.generation, + payload.metadata == nil || payload.metadata?.sessionId == receipt.serverSessionID else { return false } + // Sony's protocol dispatcher returns before executing an unknown command. + // ATT errors, retryable flags and arbitrary business errors prove nothing + // about a non-idempotent toggle's execution and must keep the reservation. + if payload.domain == .protocol, payload.code == "unknown_command" { + inFlight = nil + cancelDesired() + rejectedAtRevision = confirmedRevision + controlState = .failed + stateQueryNeeded = true + } else { + markUnknown() + } + return true + } + + mutating func expire(now: TimeInterval) { + if let receipt, now >= receipt.sentAt + confirmationTimeout { markUnknown() } + if let desiredExpiresAt, now >= desiredExpiresAt { + cancelDesired() + if inFlight == nil { controlState = .failed } + } + } + + mutating func takeStateQuery() -> Bool { + let needed = stateQueryNeeded + stateQueryNeeded = false + return needed + } + + mutating func cancelDesired() { + desired = nil + desiredExpiresAt = nil + if inFlight == nil, rejectedAtRevision == nil { controlState = .idle } + } + + private mutating func markUnknown() { + guard controlState != .unknown else { return } + cancelDesired() + controlState = .unknown + stateQueryNeeded = true + } mutating func clear() { identity = nil desired = nil + desiredExpiresAt = nil confirmed = nil confirmedRevision = 0 inFlight = nil + rejectedAtRevision = nil + stateQueryNeeded = false + controlState = .idle } - private mutating func adoptIdentity(_ snapshot: LyricsPresentationSnapshot) { - if identity?.trackID != snapshot.trackID || identity?.generation != snapshot.trackGeneration { + private mutating func adoptIdentity(_ snapshot: LyricsPresentationSnapshot, + connectionEpoch: UInt64, serverSessionID: String) { + if identity?.trackID != snapshot.trackID || identity?.generation != snapshot.trackGeneration || + identity?.connectionEpoch != connectionEpoch || identity?.serverSessionID != serverSessionID { clear() - identity = (snapshot.trackID, snapshot.trackGeneration) + identity = (snapshot.trackID, snapshot.trackGeneration, connectionEpoch, serverSessionID) } } @@ -192,18 +308,21 @@ struct LyricsPlaybackTargetPolicy { guard confirmed == nil || revision > confirmedRevision else { return } confirmed = snapshot.isPlaying confirmedRevision = revision + if let rejectedAtRevision, revision > rejectedAtRevision { self.rejectedAtRevision = nil } if let inFlight, revision > inFlight.confirmationRevision, snapshot.isPlaying == inFlight.target { self.inFlight = nil + controlState = .idle } } private mutating func reserveCommand() -> Bool { guard let desired, let confirmed, inFlight == nil else { return false } guard desired != confirmed else { - self.desired = nil + cancelDesired() return false } - inFlight = (desired, confirmedRevision) + inFlight = InFlight(target: desired, confirmationRevision: confirmedRevision) + controlState = .pending return true } } diff --git a/IOSBleFeasibility/IOSBleFeasibility/LyricsSampleBufferRenderer.swift b/IOSBleFeasibility/IOSBleFeasibility/LyricsSampleBufferRenderer.swift index fa191e8..c96aef4 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LyricsSampleBufferRenderer.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LyricsSampleBufferRenderer.swift @@ -70,6 +70,16 @@ final class LyricsSampleBufferRenderer: @unchecked Sendable { withAttributes: attributes ) + if snapshot.playbackControlState != .idle { + let feedbackParagraph = NSMutableParagraphStyle() + feedbackParagraph.alignment = .center + (snapshot.playbackControlState.message as NSString).draw( + in: CGRect(x: 36, y: 312, width: 568, height: 42), + withAttributes: [.font: UIFont.systemFont(ofSize: 16), + .foregroundColor: UIColor.lightGray, .paragraphStyle: feedbackParagraph] + ) + } + var format: CMVideoFormatDescription? guard CMVideoFormatDescriptionCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: buffer, formatDescriptionOut: &format diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift index 1fd930f..d3fc4a8 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift @@ -310,6 +310,75 @@ final class LyricsPresentationTests: XCTestCase { XCTAssertEqual(sink.states.count, 2) } + @MainActor + func testEndBeforePublicationTaskStartsNeverEntersPublisher() async throws { + let sink = ActivityPublicationRecorder() + let manager = activityManager(sink) + publishFixture(manager) + manager.end() + try await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(sink.states.count, 0) + } + + @MainActor + func testSentPlaybackRejectionRequiresFreshStateAndExplicitRetry() { + var sequences: [UInt64] = [] + let manager = BLETestManager(automaticallyStartBluetooth: false, + floatingPlaybackCommandSender: { _, sequence, _ in + sequences.append(sequence) + return .sent + }) + let id = "lyrics-terminal-error-\(UUID().uuidString)" + manager.configureResponseTests(trackID: id) + manager.commandSenderForTesting = { _, _ in true } + manager.receiveStatusForTesting(["type": "clientCapabilitiesAck", "protocolVersion": 3, + "f2": 0, "f3": 2, "sid": "1234abcd"]) + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .pending) + manager.receiveStatusForTesting(["type": "commandError", "cmd": "PLAY_PAUSE", + "seq": String(sequences[0]), "domain": "protocol", + "code": "unknown_command", "retryable": false, "trackId": id]) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .failed) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false), "A report from before the rejection is insufficient") + receivePlayback(manager, playing: true) + XCTAssertEqual(sequences.count, 1, "Failure must not automatically repeat the toggle") + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(sequences.count, 2, "A proved rejection must allow an explicit retry after fresh state") + manager.disconnectResponseTests() + } + + @MainActor + func testImmediateEndAndRestartOnlyPublishesNewSession() async throws { + let sink = ActivityPublicationRecorder() + let manager = activityManager(sink) + publishFixture(manager, lyric: "obsolete") + manager.end() + publishFixture(manager, lyric: "new session") + try await waitUntil { !sink.states.isEmpty } + try await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(sink.states.map(\.lyric), ["new session"]) + } + + @MainActor + func testOldCompletionCannotReleaseNewSessionPublication() async throws { + let sink = ActivityPublicationRecorder(suspended: true) + let manager = activityManager(sink) + publishFixture(manager, lyric: "old") + try await waitUntil { sink.states.count == 1 } + manager.end() + publishFixture(manager, lyric: "new") + try await waitUntil { sink.states.count == 2 } + publishFixture(manager, lyric: "new latest") + sink.completeNext() + try await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(sink.states.map(\.lyric), ["old", "new"]) + sink.completeNext() + try await waitUntil { sink.states.count == 3 } + XCTAssertEqual(sink.states.map(\.lyric), ["old", "new", "new latest"]) + sink.completeNext() + } + @MainActor func testEndedActivityRejectsLateCompletionAndPendingContent() async throws { let sink = ActivityPublicationRecorder(suspended: true) @@ -398,6 +467,10 @@ final class LyricsPresentationTests: XCTestCase { // A legitimate legacy packet has a position but no clock fields. receivePlayback(manager, playing: false) XCTAssertEqual(manager.makeLyricsPresentationSnapshot().validUntilUptime, 356) + // The accepted legacy sample releases the first reservation, but a + // 46-second-old unsent intent must not be executed automatically. + XCTAssertEqual(commands, [.playPause]) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) XCTAssertEqual(commands, [.playPause, .playPause]) manager.disconnectResponseTests() } @@ -462,6 +535,201 @@ final class LyricsPresentationTests: XCTestCase { manager.disconnectResponseTests() } + @MainActor + func testUnexecutedToggleBecomesUnknownWithOneQueryAndNoBlindRetry() { + var now: TimeInterval = 10 + var sent = 0 + var queries = 0 + let manager = BLETestManager(automaticallyStartBluetooth: false, lyricsPresentationUptime: { now }, + floatingPlaybackCommandSender: { _, _, _ in sent += 1; return .sent }) + manager.configureResponseTests(trackID: "lyrics-unknown-\(UUID().uuidString)") + manager.commandSenderForTesting = { command, _ in + if command == "GET_PLAYBACK_STATE" { queries += 1 } + return true + } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) + now = 19 + for _ in 0..<20 { + receivePlayback(manager, playing: true) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + } + XCTAssertEqual(sent, 1) + XCTAssertEqual(queries, 1) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .unknown) + XCTAssertEqual(manager.makeLyricsPresentationSnapshot().playbackControlState, .unknown) + XCTAssertFalse(manager.lyricsStore.floatingPlaybackControlState.message.isEmpty) + // A late confirmation can resolve the sent command, but not revive the + // expired final play intent. Later Sony-side changes remain authoritative. + receivePlayback(manager, playing: false) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .idle) + receivePlayback(manager, playing: true) + XCTAssertEqual(sent, 1) + manager.disconnectResponseTests() + } + + @MainActor + func testMissingReportsReachUnknownWithoutAnotherUserRequest() async throws { + var sent = 0 + var queries = 0 + let manager = BLETestManager(automaticallyStartBluetooth: false, + floatingPlaybackCommandSender: { _, _, _ in sent += 1; return .sent }, + floatingPlaybackConfirmationTimeout: 0.06) + manager.configureResponseTests(trackID: "lyrics-missing-report-\(UUID().uuidString)") + manager.commandSenderForTesting = { command, _ in + if command == "GET_PLAYBACK_STATE" { queries += 1 } + return true + } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + try await waitUntil { manager.lyricsStore.floatingPlaybackControlState == .unknown } + XCTAssertEqual(queries, 1) + XCTAssertEqual(sent, 1) + receivePlayback(manager, playing: true) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(queries, 1) + manager.disconnectResponseTests() + } + + @MainActor + func testUnclassifiedCommandErrorKeepsToggleReservation() { + var sequences: [UInt64] = [] + let manager = playbackErrorFixture { sequences.append($0) } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + receivePlaybackError(manager, sequence: sequences[0], code: "execution_failed", domain: "connection") + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .unknown) + receivePlayback(manager, playing: true) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(sequences.count, 1) + // Only a correlated error with known pre-execution semantics can release + // the ambiguous command, and then a fresh report is still required. + receivePlaybackError(manager, sequence: sequences[0]) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(sequences.count, 2) + manager.disconnectResponseTests() + } + + @MainActor + func testOldSequencesGenerationsAndSessionsCannotFailCurrentCommand() { + var sequences: [UInt64] = [] + let manager = playbackErrorFixture { sequences.append($0) } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + receivePlaybackError(manager, sequence: sequences[0] + 100) + receivePlaybackError(manager, sequence: sequences[0], generation: 2) + receivePlaybackError(manager, sequence: sequences[0], sessionID: "8765dcba") + XCTAssertEqual(manager.serverSessionId, "1234abcd") + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .pending) + receivePlayback(manager, playing: false) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) + receivePlaybackError(manager, sequence: sequences[0]) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .pending) + manager.disconnectResponseTests() + manager.configureResponseTests(trackID: "error-fixture") + manager.receiveStatusForTesting(["type": "clientCapabilitiesAck", "protocolVersion": 3, + "f2": 0, "f3": 3, "sid": "1234abcd"]) + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + receivePlaybackError(manager, sequence: sequences[1]) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .pending) + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(sequences.count, 3) + manager.disconnectResponseTests() + } + + @MainActor + func testOldServerSessionReportCannotConfirmCurrentToggle() { + var sequences: [UInt64] = [] + let manager = playbackErrorFixture { sequences.append($0) } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) + manager.receiveStatusForTesting(["type": "playbackState", "playing": false, "position": 1_000, + "duration": 30_000, "sid": "8765dcba", "es": 1]) + XCTAssertEqual(sequences.count, 1) + XCTAssertFalse(manager.makeLyricsPresentationSnapshot().hasAuthoritativePlayback) + manager.receiveStatusForTesting(["type": "playbackState", "playing": true, "position": 1_000, + "duration": 30_000, "sid": "1234abcd", "es": 2]) + XCTAssertEqual(sequences.count, 1) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .pending) + receivePlayback(manager, playing: false) + XCTAssertEqual(sequences.count, 2) + manager.disconnectResponseTests() + } + + @MainActor + func testOldConnectionQueuedReportCannotConfirmNewCommand() async throws { + var sequences: [UInt64] = [] + let manager = playbackErrorFixture { sequences.append($0) } + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + let queued = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + manager.receiveStatusForTesting(["type": "playbackState", "playing": false, + "position": 1_000, "duration": 30_000]) + queued.signal() + } + XCTAssertEqual(queued.wait(timeout: .now() + 2), .success) + manager.disconnectResponseTests() + manager.configureResponseTests(trackID: "error-fixture") + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) + try await Task.sleep(nanoseconds: 30_000_000) + XCTAssertTrue(manager.makeLyricsPresentationSnapshot().isPlaying) + XCTAssertEqual(sequences.count, 2) + manager.disconnectResponseTests() + } + + @MainActor + func testStopAndReopenPreserveUnknownProtectionAndReconnectAllowsExplicitRecovery() { + var now: TimeInterval = 10 + var sent = 0 + let manager = BLETestManager(automaticallyStartBluetooth: false, lyricsPresentationUptime: { now }, + floatingPlaybackCommandSender: { _, _, _ in sent += 1; return .sent }) + manager.commandSenderForTesting = { _, _ in true } + manager.configureResponseTests(trackID: "lyrics-reopen-\(UUID().uuidString)") + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(true)) + manager.cancelFloatingLyricsPlaybackIntent() + XCTAssertEqual(sent, 1) + now = 20 + receivePlayback(manager, playing: true) + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .unknown) + manager.disconnectResponseTests() + manager.configureResponseTests(trackID: "lyrics-reconnected") + XCTAssertFalse(manager.requestFloatingLyricsPlayback(false)) + receivePlayback(manager, playing: true) + XCTAssertEqual(sent, 1, "Reconnect must not replay an old intent") + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + XCTAssertEqual(sent, 2) + manager.disconnectResponseTests() + } + + @MainActor + func testDeferredUnsentIntentExpiresInsteadOfExecutingMuchLater() async throws { + var now: TimeInterval = 10 + var attempts = 0 + let manager = BLETestManager(automaticallyStartBluetooth: false, lyricsPresentationUptime: { now }, + floatingPlaybackCommandSender: { _, _, _ in attempts += 1; return .debounced }) + manager.configureResponseTests(trackID: "lyrics-expired-unsent-\(UUID().uuidString)") + receivePlayback(manager, playing: true) + XCTAssertTrue(manager.requestFloatingLyricsPlayback(false)) + now = 20 + receivePlayback(manager, playing: true) + try await Task.sleep(nanoseconds: 800_000_000) + XCTAssertEqual(attempts, 1) + XCTAssertEqual(manager.lyricsStore.floatingPlaybackControlState, .failed) + manager.disconnectResponseTests() + } + @MainActor func testAutomaticFollowUpRespectsDebounceThenReconcilesWithoutAnotherRequest() async throws { var attempts = 0 @@ -554,6 +822,38 @@ final class LyricsPresentationTests: XCTestCase { manager.disconnectResponseTests() } + func testUnknownControlFeedbackIsRenderedWithoutChangingLyricIdentity() throws { + let renderer = LyricsSampleBufferRenderer() + let normal = snapshot() + var unknown = normal + unknown.playbackControlState = .unknown + XCTAssertEqual(normal.key, unknown.key, "Control feedback must not increase ActivityKit lyric publication") + let plain = try renderer.render(normal) + let feedback = try renderer.render(unknown) + func brightFooterPixels(_ sample: CMSampleBuffer) throws -> Int { + let buffer = try XCTUnwrap(CMSampleBufferGetImageBuffer(sample)) + CVPixelBufferLockBaseAddress(buffer, .readOnly) + defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } + let bytes = try XCTUnwrap(CVPixelBufferGetBaseAddress(buffer)).assumingMemoryBound(to: UInt8.self) + let stride = CVPixelBufferGetBytesPerRow(buffer) + return (312..<360).reduce(0) { count, row in + count + (36..<604).filter { column in + let index = row * stride + column * 4 + return bytes[index] > 100 && bytes[index + 1] > 100 && bytes[index + 2] > 100 + }.count + } + } + XCTAssertEqual(try brightFooterPixels(plain), 0) + XCTAssertGreaterThan(try brightFooterPixels(feedback), 100) + let buffer = try XCTUnwrap(CMSampleBufferGetImageBuffer(feedback)) + let image = CIImage(cvPixelBuffer: buffer) + let cgImage = try XCTUnwrap(CIContext().createCGImage(image, from: image.extent)) + let attachment = XCTAttachment(image: UIImage(cgImage: cgImage)) + attachment.name = "floating-lyrics-unknown-control-feedback" + attachment.lifetime = .keepAlways + add(attachment) + } + func testSampleBufferPixelsAndTimestampsRemainValidAcrossBackwardSeek() throws { let renderer = LyricsSampleBufferRenderer() let first = try renderer.render(snapshot()) @@ -707,6 +1007,31 @@ final class LyricsPresentationTests: XCTestCase { "duration": 30_000, "lyric": "fixture lyric"]) } + @MainActor + private func playbackErrorFixture(recordSequence: @escaping (UInt64) -> Void) -> BLETestManager { + let manager = BLETestManager(automaticallyStartBluetooth: false, + floatingPlaybackCommandSender: { _, sequence, _ in + recordSequence(sequence) + return .sent + }) + manager.configureResponseTests(trackID: "error-fixture") + manager.commandSenderForTesting = { _, _ in true } + manager.receiveStatusForTesting(["type": "clientCapabilitiesAck", "protocolVersion": 3, + "f2": 0, "f3": 3, "sid": "1234abcd"]) + manager.receiveStatusForTesting(["type": "trackInfo", "trackId": "error-fixture", "generation": 1, + "title": "Fixture", "artist": "Artist"]) + return manager + } + + private func receivePlaybackError(_ manager: BLETestManager, sequence: UInt64, + generation: Int = 1, sessionID: String = "1234abcd", + code: String = "unknown_command", domain: String = "protocol") { + manager.receiveStatusForTesting(["type": "commandError", "cmd": "PLAY_PAUSE", "seq": String(sequence), + "domain": domain, "code": code, "retryable": false, + "trackId": "error-fixture", "generation": generation, + "sid": sessionID, "es": sequence]) + } + private func synchronizedClock() -> MonotonicClockSynchronizer { var clock = MonotonicClockSynchronizer() let base = Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded()) - 100 diff --git a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md index 9e145fb..c98b514 100644 --- a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md +++ b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md @@ -185,3 +185,63 @@ BLE 协议、Sony/Android、偏好持久化格式、Widget 布局及工程配置 用户明确要求按本轮审计修改后提交并合并 GitHub,继续按该授权执行。 本地自动化通过与设备验收未通过分别保留,合并不等于真机验收或发布通过。 + +## 2026-10-06 最新审计 N1/N2 收尾 + +审计来源为[新的分享对话](https://chatgpt.com/share/6ac43a6b-7e20-83e9-aea7-0d6506220344), +实际远端与开发基线均为 `b57ba7f0893a8fa0cc7353f1ab639b4e39726745`。 +分支为 `codex/ios-lyrics-audit-n1-n2`。本轮不重做已关闭的 R1~R3 或 R4 正常协调路径,保留 R5。 +开始时主工作目录干净;PR #12 已交付的六项修改及其备份 stash 均不重复应用。 + +### N1:发送后的明确拒绝与未知结果 + +- `LyricsPlaybackTargetPolicy` 记录实际命令 `seq`、歌曲/代际、连接 epoch、协商的 Sony 会话以及单调发送时间。 + `.sent` 表示发送入口接受,不是媒体执行成功;`sendLiveActivityCommand` 也开始检查实际入队返回值。 +- `handleStructuredCommandError` 接回同一策略。只有匹配请求的 `protocol/unknown_command` 被视为明确未执行: + Sony 分发器在该错误分支返回,没有执行媒体命令。释放后取消旧意图,要求失败之后的新有效播放回报,再由用户显式重试。 + `retryable`、ATT 回调、未知业务错误、相同状态和超时均不能证明未执行,不据此释放 toggle 保护。 +- 已发送命令八秒未确认,或收到匹配但语义不明确的错误,进入 `unknown`,取消未发送的最后意图,并查询一次既有 + `GET_PLAYBACK_STATE`。持续相同回报和重复请求不会再查询或再 toggle。若随后收到明确拒绝,可再查询一次以恢复可重试状态;没有轮询循环。 +- 未发送意图同样有八秒期限。PiP 停止取消未发送意图、保留已发送命令的保护及有界结果期限;重开不盲重发。 + 迟到的目标确认可以结束原命令,但不会执行已过期的后续意图,也不会对抗之后的 Sony 手动控制。 +- `LyricsStore` 与展示快照携带待确认/未知/失败提示。PiP 使用已有 1Hz active 心跳更新底部提示,应用内展示相同状态, + 未知状态提供已有手动重新连接入口。重新连接取消旧意图,新的有效采样之后仍需用户重新操作。 + 控制提示不进入歌词语义 key,不增加 ActivityKit 歌词发布频率。 +- 异步接收入口及主线程应用检查连接 epoch;错误检查 seq、代际和会话。旧会话播放采样不能确认当前 PiP 命令, + 但不以 `es` 全局丢弃其他状态。无 `sid` 或时钟字段的合法旧协议继续使用既有回退。 + +当前协议没有媒体操作的逐命令成功 ACK,也没有为 Sony 找不到媒体控制器的直接返回路径发送业务失败。 +因此这一路只能安全显示结果未知并提供恢复入口,不能声称状态查询或重新连接证明了原操作未执行。 +本次没有新增协议字段、错误码或 Sony 执行行为。 + +### N2:发布副作用之前检查生命周期身份 + +`LiveActivityManager` 持有可取消的 publication Task,`end` 先失效 epoch 并取消任务和 observer。 +Task 启动、`publish` 入口、查找/恢复/创建活动前,以及 await 返回后下一次系统 update 前均检查 epoch 与取消状态。 +完成后仅当前 epoch 可以回写或推进队列;旧 observer 还必须匹配当前活动 ID。 +正在结束的活动 ID 不参与恢复,也不恢复 ended/dismissed 活动,旧重复活动清理任务不能结束新会话选中的活动。 +已经提交给 ActivityKit 的系统调用无法强制撤回,此修复保证旧任务不会继续产生下一项副作用或污染新会话。 +关闭紧凑歌词仍仅恢复原布局,不调用 `end`;关闭 PiP 不发送 Sony 暂停。 + +### 回归证据与验收边界 + +修复前先加入两项生产接入回归,并执行完整工程中的这两项 XCTest,均按预期失败: +连续 `update → end` 后发布次数为 1(期望 0);`.sent → 匹配明确拒绝 → 新状态 → 显式重试` 的命令数为 1(期望 2)。 +证据为 `/private/tmp/musicble-n1-n2-before.xcresult` 与 `/private/tmp/musicble-n1-n2-before.log`。 + +| 检查 | 最终结果与证据 | +|---|---| +| 完整 iPhone 16 Pro / iOS 18.3 模拟器 XCTest | **首轮 PASS 117/117**:50 项歌词展示、67 项原稳定性测试。本轮新增 13 项,覆盖明确拒绝恢复、20 次同状态回报/请求、无回报期限、未知错误保护、旧 seq/代际/会话/连接隔离、停止重开与重连恢复、意图过期、实际提示像素,以及未启动/重开/在途完成生命周期。`/private/tmp/musicble-n1-n2-all.xcresult`、`/private/tmp/musicble-n1-n2-all.log` | +| R3 与意图期限共同回归 | 陈旧采样仍不续期或确认 toggle,合法旧协议仍能恢复。46 秒后到达的合法确认只释放原命令;旧后续意图已过期,新的显式播放请求才发送第二条命令。这是增加过期语义,不放宽 R3 断言。 | +| generic iOS App / Widget 构建 | **BUILD SUCCEEDED**,无 Swift 源码编译警告;`/private/tmp/musicble-n1-n2-device-build.log` | +| 真实渲染器附件 | 新 unknown 提示确实进入 640×360 像素缓冲,已导出并目视检查;文字、背景和原歌词区域正常。附件名 `floating-lyrics-unknown-control-feedback`,不是系统 PiP 窗口验收。 | +| quick smoke | **overall FAIL,Required 2/6**;两个 PASS 是 quick 跳过构建/安装。`Tm iPhone` 仍 unavailable,CoreDevice 无法定位设备,启动、日志、偏好和容器未验证;三个可选链路 SKIPPED。`/private/tmp/musicble-n1-n2-quick-smoke/report.json`、`ios_launch_stderr.log` | +| full smoke / 本地 Android build | 本轮未触及启动、安装、UserDefaults、日志系统或工程设置,**不要求 full smoke**;没有 Android/Sony 改动,**不要求本地 Android build**。GitHub iOS/Android CI 应按实际提交另外核对,不能以旧 master CI 代替。 | +| 图谱与差异 | 修改前核对索引、精确符号、调用路径并读取真实源码。修改后完整索引成功(8231 nodes / 43869 edges),调用图显示错误入口接入策略;当前接口未暴露 `list_projects/index_status/detect_changes`,使用完整索引、实际差异与调用位置复核。`git diff --check`、String Catalog JSON 检查通过;未生成仓库内图谱文件。 | +| 首轮/重试口径 | 本次最终本地 117 项首轮通过,没有为通过而重复执行完整测试。原快照存储 `load == nil` 的历史 CI 间歇失败未在本次最终本地轮次出现,也未在本轮修复;后续 CI 首轮与自动重试须分别报告。 | + +两项设置仍独立默认关闭;默认关闭不会启动 PiP/音频/渲染心跳,也不会创建控制结果期限任务或状态查询。 +新增有界任务只在用户提出 PiP 播放意图且命令入口接受后创建。BLE UUID、JSON/A1/A2/FullLyrics/secondary、 +Sony/Android、偏好格式、Widget 布局和工程配置不变。 +跨应用 PiP、灵动岛、多活动选择、Sony BLE、后台/锁屏、音频中断和长期运行仍未完成真机验收。 +用户在本会话已明确授权修改后提交并合并 GitHub;按该授权交付,保留 smoke 失败与真机待验边界。 From cfa3102f47642cf5d8e2a26e870de1a7c1c3038e Mon Sep 17 00:00:00 2001 From: sqz Date: Tue, 6 Oct 2026 08:27:53 +0800 Subject: [PATCH 2/3] Fence publication against ended activity targets --- .../IOSBleFeasibility/LiveActivityManager.swift | 6 ++++-- docs/LYRICS_DISPLAY_IMPLEMENTATION.md | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift index 67d1df0..0e48521 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift @@ -451,9 +451,11 @@ final class LiveActivityManager { guard !Task.isCancelled, publicationEpoch == epoch else { return false } if let statePublisher { return await statePublisher(state) } guard let target = await ensureActivity(for: state, epoch: epoch, logger: logger), - !Task.isCancelled, publicationEpoch == epoch else { return false } + !Task.isCancelled, publicationEpoch == epoch, activity?.id == target.id, + target.activityState == .active || target.activityState == .stale else { return false } await target.update(ActivityContent(state: state, staleDate: staleDate(for: state))) - guard !Task.isCancelled, publicationEpoch == epoch else { return false } + guard !Task.isCancelled, publicationEpoch == epoch, activity?.id == target.id, + target.activityState == .active || target.activityState == .stale else { return false } activity = target return true } diff --git a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md index c98b514..6d2c865 100644 --- a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md +++ b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md @@ -219,6 +219,7 @@ BLE 协议、Sony/Android、偏好持久化格式、Widget 布局及工程配置 `LiveActivityManager` 持有可取消的 publication Task,`end` 先失效 epoch 并取消任务和 observer。 Task 启动、`publish` 入口、查找/恢复/创建活动前,以及 await 返回后下一次系统 update 前均检查 epoch 与取消状态。 完成后仅当前 epoch 可以回写或推进队列;旧 observer 还必须匹配当前活动 ID。 +系统 update 前后也复核当前活动 ID 与 active/stale 状态,系统已结束的目标不能被迟到 update 恢复为当前活动。 正在结束的活动 ID 不参与恢复,也不恢复 ended/dismissed 活动,旧重复活动清理任务不能结束新会话选中的活动。 已经提交给 ActivityKit 的系统调用无法强制撤回,此修复保证旧任务不会继续产生下一项副作用或污染新会话。 关闭紧凑歌词仍仅恢复原布局,不调用 `end`;关闭 PiP 不发送 Sony 暂停。 @@ -231,13 +232,13 @@ Task 启动、`publish` 入口、查找/恢复/创建活动前,以及 await | 检查 | 最终结果与证据 | |---|---| -| 完整 iPhone 16 Pro / iOS 18.3 模拟器 XCTest | **首轮 PASS 117/117**:50 项歌词展示、67 项原稳定性测试。本轮新增 13 项,覆盖明确拒绝恢复、20 次同状态回报/请求、无回报期限、未知错误保护、旧 seq/代际/会话/连接隔离、停止重开与重连恢复、意图过期、实际提示像素,以及未启动/重开/在途完成生命周期。`/private/tmp/musicble-n1-n2-all.xcresult`、`/private/tmp/musicble-n1-n2-all.log` | +| 完整 iPhone 16 Pro / iOS 18.3 模拟器 XCTest | **首轮 PASS 117/117**:50 项歌词展示、67 项原稳定性测试。本轮新增 13 项,覆盖明确拒绝恢复、20 次同状态回报/请求、无回报期限、未知错误保护、旧 seq/代际/会话/连接隔离、停止重开与重连恢复、意图过期、实际提示像素,以及未启动/重开/在途完成生命周期。最终活动 ID/state 补强后重新验证该版本,仍首轮 117/117;`/private/tmp/musicble-n1-n2-final.xcresult`、`/private/tmp/musicble-n1-n2-final.log` | | R3 与意图期限共同回归 | 陈旧采样仍不续期或确认 toggle,合法旧协议仍能恢复。46 秒后到达的合法确认只释放原命令;旧后续意图已过期,新的显式播放请求才发送第二条命令。这是增加过期语义,不放宽 R3 断言。 | -| generic iOS App / Widget 构建 | **BUILD SUCCEEDED**,无 Swift 源码编译警告;`/private/tmp/musicble-n1-n2-device-build.log` | +| generic iOS App / Widget 构建 | **BUILD SUCCEEDED**,无 Swift 源码编译警告;`/private/tmp/musicble-n1-n2-device-build-final.log` | | 真实渲染器附件 | 新 unknown 提示确实进入 640×360 像素缓冲,已导出并目视检查;文字、背景和原歌词区域正常。附件名 `floating-lyrics-unknown-control-feedback`,不是系统 PiP 窗口验收。 | -| quick smoke | **overall FAIL,Required 2/6**;两个 PASS 是 quick 跳过构建/安装。`Tm iPhone` 仍 unavailable,CoreDevice 无法定位设备,启动、日志、偏好和容器未验证;三个可选链路 SKIPPED。`/private/tmp/musicble-n1-n2-quick-smoke/report.json`、`ios_launch_stderr.log` | +| quick smoke | **overall FAIL,Required 2/6**;两个 PASS 是 quick 跳过构建/安装。`Tm iPhone` 仍 unavailable,CoreDevice 无法定位设备,启动、日志、偏好和容器未验证;三个可选链路 SKIPPED。最终版本记录 `/private/tmp/musicble-n1-n2-final-quick-smoke/report.json`、`ios_launch_stderr.log` | | full smoke / 本地 Android build | 本轮未触及启动、安装、UserDefaults、日志系统或工程设置,**不要求 full smoke**;没有 Android/Sony 改动,**不要求本地 Android build**。GitHub iOS/Android CI 应按实际提交另外核对,不能以旧 master CI 代替。 | -| 图谱与差异 | 修改前核对索引、精确符号、调用路径并读取真实源码。修改后完整索引成功(8231 nodes / 43869 edges),调用图显示错误入口接入策略;当前接口未暴露 `list_projects/index_status/detect_changes`,使用完整索引、实际差异与调用位置复核。`git diff --check`、String Catalog JSON 检查通过;未生成仓库内图谱文件。 | +| 图谱与差异 | 修改前核对索引、精确符号、调用路径并读取真实源码。修改后完整索引成功(8235 nodes / 43895 edges),调用图显示错误入口接入策略;当前接口未暴露 `list_projects/index_status/detect_changes`,使用完整索引、实际差异与调用位置复核。`git diff --check`、String Catalog JSON 检查通过;未生成仓库内图谱文件。 | | 首轮/重试口径 | 本次最终本地 117 项首轮通过,没有为通过而重复执行完整测试。原快照存储 `load == nil` 的历史 CI 间歇失败未在本次最终本地轮次出现,也未在本轮修复;后续 CI 首轮与自动重试须分别报告。 | 两项设置仍独立默认关闭;默认关闭不会启动 PiP/音频/渲染心跳,也不会创建控制结果期限任务或状态查询。 From 79f9bfc329b16a91bfd98c2540f7aac401e5aa20 Mon Sep 17 00:00:00 2001 From: sqz Date: Tue, 6 Oct 2026 08:34:05 +0800 Subject: [PATCH 3/3] Finish duplicate activity cleanup across session end --- .../LiveActivityManager.swift | 38 ++++++++++------ .../LyricsPresentationTests.swift | 43 +++++++++++++++++++ docs/LYRICS_DISPLAY_IMPLEMENTATION.md | 13 +++--- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift index 0e48521..3e8a19b 100644 --- a/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift +++ b/IOSBleFeasibility/IOSBleFeasibility/LiveActivityManager.swift @@ -1,6 +1,25 @@ @preconcurrency import ActivityKit import Foundation +/// Ending resources is independent of the publication epoch: an end() must +/// finish already scheduled cleanup, while a newly selected target is protected. +@MainActor +final class LiveActivityCleanupQueue { + private var activityIDs: Set = [] + + func contains(_ id: String) -> Bool { activityIDs.contains(id) } + + func enqueue(id: String, canEnd: @escaping () -> Bool = { true }, + end: @escaping () async -> Void) { + guard activityIDs.insert(id).inserted else { return } + Task { + defer { activityIDs.remove(id) } + guard canEnd() else { return } + await end() + } + } +} + @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() @@ -15,7 +34,7 @@ final class LiveActivityManager { private var activity: Activity? private var activityStateTask: Task? private var publicationTask: Task? - private var endingActivityIDs: Set = [] + private let activityCleanup = LiveActivityCleanupQueue() private var latestState: SonyMusicActivityAttributes.ContentState? private var latestReason = "unknown" private var latestForce = false @@ -169,8 +188,7 @@ final class LiveActivityManager { activityStateTask = nil guard let activity else { return } self.activity = nil - endingActivityIDs.insert(activity.id) - Task { + activityCleanup.enqueue(id: activity.id) { if let state { await activity.end( ActivityContent(state: state, staleDate: nil), @@ -179,7 +197,6 @@ final class LiveActivityManager { } else { await activity.end(nil, dismissalPolicy: .immediate) } - endingActivityIDs.remove(activity.id) logger?("[LiveActivity] end") } } @@ -525,7 +542,7 @@ final class LiveActivityManager { guard !Task.isCancelled, publicationEpoch == epoch else { return nil } let activities = Activity.activities guard let existing = activities.first(where: { - !endingActivityIDs.contains($0.id) && ($0.activityState == .active || $0.activityState == .stale) + !activityCleanup.contains($0.id) && ($0.activityState == .active || $0.activityState == .stale) }) else { return nil } activity = existing observeStateUpdates(for: existing, epoch: epoch, logger: logger) @@ -541,14 +558,11 @@ final class LiveActivityManager { ) { guard !Task.isCancelled, publicationEpoch == epoch else { return } for duplicate in Activity.activities - where duplicate.id != activityID && !endingActivityIDs.contains(duplicate.id) { + where duplicate.id != activityID && !activityCleanup.contains(duplicate.id) { logger?("[LiveActivity] duplicate activity ending id=\(duplicate.id)") - endingActivityIDs.insert(duplicate.id) - Task { [weak self] in - guard let self else { return } - defer { self.endingActivityIDs.remove(duplicate.id) } - guard !Task.isCancelled, self.publicationEpoch == epoch, - self.activity?.id != duplicate.id else { return } + activityCleanup.enqueue(id: duplicate.id, canEnd: { [weak self] in + self?.activity?.id != duplicate.id + }) { await duplicate.end(nil, dismissalPolicy: .immediate) } } diff --git a/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift b/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift index d3fc4a8..bb885e4 100644 --- a/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift +++ b/IOSBleFeasibility/IOSBleFeasibilityTests/LyricsPresentationTests.swift @@ -310,6 +310,49 @@ final class LyricsPresentationTests: XCTestCase { XCTAssertEqual(sink.states.count, 2) } + @MainActor + func testScheduledDuplicateCleanupFinishesAfterCurrentSessionEnds() async throws { + let cleanup = LiveActivityCleanupQueue() + var currentID: String? = "current" + var ended = 0 + cleanup.enqueue(id: "duplicate", canEnd: { currentID != "duplicate" }) { ended += 1 } + XCTAssertTrue(cleanup.contains("duplicate")) + // The session ends in the same MainActor slice, before cleanup starts. + currentID = nil + try await waitUntil { ended == 1 } + XCTAssertFalse(cleanup.contains("duplicate")) + } + + @MainActor + func testScheduledDuplicateCleanupCannotEndNewlySelectedActivity() async throws { + let cleanup = LiveActivityCleanupQueue() + var currentID = "old current" + var ended = 0 + cleanup.enqueue(id: "duplicate", canEnd: { currentID != "duplicate" }) { ended += 1 } + currentID = "duplicate" + try await Task.sleep(nanoseconds: 30_000_000) + XCTAssertEqual(ended, 0) + XCTAssertFalse(cleanup.contains("duplicate")) + } + + @MainActor + func testActivityRemainsExcludedUntilItsSingleCleanupCompletes() async throws { + let cleanup = LiveActivityCleanupQueue() + var ends = 0 + var completion: CheckedContinuation? + cleanup.enqueue(id: "ending") { + ends += 1 + await withCheckedContinuation { completion = $0 } + } + cleanup.enqueue(id: "ending") { ends += 1 } + try await waitUntil { completion != nil } + XCTAssertEqual(ends, 1) + XCTAssertTrue(cleanup.contains("ending")) + completion?.resume() + try await waitUntil { !cleanup.contains("ending") } + XCTAssertEqual(ends, 1) + } + @MainActor func testEndBeforePublicationTaskStartsNeverEntersPublisher() async throws { let sink = ActivityPublicationRecorder() diff --git a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md index 6d2c865..109df59 100644 --- a/docs/LYRICS_DISPLAY_IMPLEMENTATION.md +++ b/docs/LYRICS_DISPLAY_IMPLEMENTATION.md @@ -221,6 +221,9 @@ Task 启动、`publish` 入口、查找/恢复/创建活动前,以及 await 完成后仅当前 epoch 可以回写或推进队列;旧 observer 还必须匹配当前活动 ID。 系统 update 前后也复核当前活动 ID 与 active/stale 状态,系统已结束的目标不能被迟到 update 恢复为当前活动。 正在结束的活动 ID 不参与恢复,也不恢复 ended/dismissed 活动,旧重复活动清理任务不能结束新会话选中的活动。 +PR 审查发现重复活动清理也使用 epoch 会使 `end` 之后尚未开始的清理被跳过,留下旧活动。 +因此实际结束操作交给独立的 `LiveActivityCleanupQueue`:已安排的清理继续完成,执行前仍检查当前选中的 ID, +结束完成前始终排除恢复,同一 ID 只安排一次结束。三项新增测试驱动生产清理队列的实际异步回调,验证结束后继续清理、保护新选中目标和在途排除/去重。 已经提交给 ActivityKit 的系统调用无法强制撤回,此修复保证旧任务不会继续产生下一项副作用或污染新会话。 关闭紧凑歌词仍仅恢复原布局,不调用 `end`;关闭 PiP 不发送 Sony 暂停。 @@ -232,14 +235,14 @@ Task 启动、`publish` 入口、查找/恢复/创建活动前,以及 await | 检查 | 最终结果与证据 | |---|---| -| 完整 iPhone 16 Pro / iOS 18.3 模拟器 XCTest | **首轮 PASS 117/117**:50 项歌词展示、67 项原稳定性测试。本轮新增 13 项,覆盖明确拒绝恢复、20 次同状态回报/请求、无回报期限、未知错误保护、旧 seq/代际/会话/连接隔离、停止重开与重连恢复、意图过期、实际提示像素,以及未启动/重开/在途完成生命周期。最终活动 ID/state 补强后重新验证该版本,仍首轮 117/117;`/private/tmp/musicble-n1-n2-final.xcresult`、`/private/tmp/musicble-n1-n2-final.log` | +| 完整 iPhone 16 Pro / iOS 18.3 模拟器 XCTest | **最终版本首轮 PASS 120/120**:53 项歌词展示、67 项原稳定性测试。本轮新增 16 项,覆盖明确拒绝恢复、20 次同状态回报/请求、无回报期限、未知错误保护、旧 seq/代际/会话/连接隔离、停止重开与重连恢复、意图过期、实际提示像素,以及未启动/重开/在途完成生命周期与重复活动清理。审查前两个代码版本均首轮 117/117;新增清理队列回归后验证当前版本为 120/120。`/private/tmp/musicble-n1-n2-reviewed.xcresult`、`/private/tmp/musicble-n1-n2-reviewed.log` | | R3 与意图期限共同回归 | 陈旧采样仍不续期或确认 toggle,合法旧协议仍能恢复。46 秒后到达的合法确认只释放原命令;旧后续意图已过期,新的显式播放请求才发送第二条命令。这是增加过期语义,不放宽 R3 断言。 | -| generic iOS App / Widget 构建 | **BUILD SUCCEEDED**,无 Swift 源码编译警告;`/private/tmp/musicble-n1-n2-device-build-final.log` | +| generic iOS App / Widget 构建 | **BUILD SUCCEEDED**,无 Swift 源码编译警告;`/private/tmp/musicble-n1-n2-device-build-reviewed.log` | | 真实渲染器附件 | 新 unknown 提示确实进入 640×360 像素缓冲,已导出并目视检查;文字、背景和原歌词区域正常。附件名 `floating-lyrics-unknown-control-feedback`,不是系统 PiP 窗口验收。 | -| quick smoke | **overall FAIL,Required 2/6**;两个 PASS 是 quick 跳过构建/安装。`Tm iPhone` 仍 unavailable,CoreDevice 无法定位设备,启动、日志、偏好和容器未验证;三个可选链路 SKIPPED。最终版本记录 `/private/tmp/musicble-n1-n2-final-quick-smoke/report.json`、`ios_launch_stderr.log` | +| quick smoke | **overall FAIL,Required 2/6**;两个 PASS 是 quick 跳过构建/安装。`Tm iPhone` 仍 unavailable,CoreDevice 无法定位设备,启动、日志、偏好和容器未验证;三个可选链路 SKIPPED。最终版本记录 `/private/tmp/musicble-n1-n2-reviewed-quick-smoke/report.json`、`ios_launch_stderr.log` | | full smoke / 本地 Android build | 本轮未触及启动、安装、UserDefaults、日志系统或工程设置,**不要求 full smoke**;没有 Android/Sony 改动,**不要求本地 Android build**。GitHub iOS/Android CI 应按实际提交另外核对,不能以旧 master CI 代替。 | -| 图谱与差异 | 修改前核对索引、精确符号、调用路径并读取真实源码。修改后完整索引成功(8235 nodes / 43895 edges),调用图显示错误入口接入策略;当前接口未暴露 `list_projects/index_status/detect_changes`,使用完整索引、实际差异与调用位置复核。`git diff --check`、String Catalog JSON 检查通过;未生成仓库内图谱文件。 | -| 首轮/重试口径 | 本次最终本地 117 项首轮通过,没有为通过而重复执行完整测试。原快照存储 `load == nil` 的历史 CI 间歇失败未在本次最终本地轮次出现,也未在本轮修复;后续 CI 首轮与自动重试须分别报告。 | +| 图谱与差异 | 修改前核对索引、精确符号、调用路径并读取真实源码。修改后完整索引成功(8242 nodes / 43955 edges),调用图显示错误入口接入策略;Swift 闭包及同名方法存在误连,清理队列调用关系另用真实源码确认。当前接口未暴露 `list_projects/index_status/detect_changes`,使用完整索引、实际差异与调用位置复核。`git diff --check`、String Catalog JSON 检查通过;未生成仓库内图谱文件。 | +| 首轮/重试口径 | 最终本地 120 项首轮通过,完整测试仅在代码改变后重新验证,没有为通过而重复执行。原快照存储 `load == nil` 的历史 CI 间歇失败未在本次最终本地轮次出现,也未在本轮修复;后续 CI 首轮与自动重试须分别报告。 | 两项设置仍独立默认关闭;默认关闭不会启动 PiP/音频/渲染心跳,也不会创建控制结果期限任务或状态查询。 新增有界任务只在用户提出 PiP 播放意图且命令入口接受后创建。BLE UUID、JSON/A1/A2/FullLyrics/secondary、