Harden PTT audio and the 1.7.1 release (#1423)

Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
This commit is contained in:
jack
2026-07-10 14:04:09 +02:00
committed by GitHub
parent b205a55523
commit 820c933958
91 changed files with 8381 additions and 1083 deletions
@@ -63,6 +63,10 @@ final class VoiceRecordingViewModel: ObservableObject {
/// live push-to-talk session when the current DM peer can hear it now.
var sessionProvider: () -> VoiceCaptureSession = { VoiceNoteCaptureSession() }
private var activeSession: VoiceCaptureSession?
/// Monotonic press identity. A slow permission/start/finalize task from an
/// older hold may still deliver its file, but it must never mutate the UI
/// state of a newer hold.
private var holdGeneration: UInt64 = 0
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
@@ -75,13 +79,18 @@ final class VoiceRecordingViewModel: ObservableObject {
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
holdGeneration &+= 1
let generation = holdGeneration
let session = sessionProvider()
SecureLogger.info("PTT: mic hold began (backend: \(session.isLive ? "live" : "classic"))", category: .session)
activeSession = session
state = .requestingPermission
Task {
let granted = await session.requestPermission()
guard state == .requestingPermission, activeSession === session else { return }
guard generation == holdGeneration,
state == .requestingPermission,
activeSession === session
else { return }
guard granted else {
state = .permissionDenied
activeSession = nil
@@ -90,16 +99,36 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .preparing
do {
try await session.start()
guard state == .preparing, activeSession === session else {
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else {
await session.cancel()
return
}
state = .recording(startDate: Date())
isLiveStreaming = session.isLive
} catch VoiceRecorder.RecorderError.recordingInProgress {
// The previous classic hold may still be in its intentional
// finalize-padding window. This press owns no recorder, so
// its owner-scoped cancel is harmless; return to idle instead
// of surfacing a false capture failure while the prior note
// finishes and delivers normally.
SecureLogger.info("Voice recording start deferred while the previous hold finalizes", category: .session)
await session.cancel()
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
activeSession = nil
state = .idle
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await session.cancel()
guard state == .preparing, activeSession === session else { return }
guard generation == holdGeneration,
state == .preparing,
activeSession === session
else { return }
// The live engine and the classic recorder are separate
// capture stacks: when the live one hits an audio-route
// glitch, fall back within the same hold so the user still
@@ -109,7 +138,10 @@ final class VoiceRecordingViewModel: ObservableObject {
activeSession = fallback
do {
try await fallback.start()
guard state == .preparing, activeSession === fallback else {
guard generation == holdGeneration,
state == .preparing,
activeSession === fallback
else {
await fallback.cancel()
return
}
@@ -120,7 +152,7 @@ final class VoiceRecordingViewModel: ObservableObject {
} catch {
SecureLogger.error("Voice recording fallback failed to start: \(error)", category: .session)
await fallback.cancel()
guard state == .preparing else { return }
guard generation == holdGeneration, state == .preparing else { return }
}
}
activeSession = nil
@@ -142,6 +174,7 @@ final class VoiceRecordingViewModel: ObservableObject {
state = .idle
isLiveStreaming = false
let session = activeSession
let generation = holdGeneration
activeSession = nil
guard case .recording(let startDate) = previousState, let completion, let session else {
@@ -159,7 +192,7 @@ final class VoiceRecordingViewModel: ObservableObject {
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
guard generation == holdGeneration, state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."