Fix dead record button after mic permission denial
The watch app's permission-denied state was a computed property reading AVAudioApplication.shared.recordPermission — external state Observation cannot track — so after a denied prompt the UI never flipped to the 'Microphone access is off' screen and the record button silently did nothing. Store the denied flag as a tracked property instead, updated on every permission request. Also: log (instead of swallow) recorder start failures, drop phantom recordings when no file was written (the watch simulator has no audio input device), sweep orphaned metadata sidecars on reconcile, and add a DEBUG-only 'autorecord' launch argument for CLI-driven testing. Claude-Session: https://claude.ai/code/session_01GKfAiKiQKLDTmbiGva4FGE
This commit is contained in:
@@ -70,6 +70,16 @@ final class WatchTransferService: NSObject {
|
|||||||
duration: meta?.duration ?? 0,
|
duration: meta?.duration ?? 0,
|
||||||
timeZoneID: meta?.timeZoneID)
|
timeZoneID: meta?.timeZoneID)
|
||||||
}
|
}
|
||||||
|
// Sweep sidecars whose audio file is gone (e.g. a simulator "recording"
|
||||||
|
// that never produced a file) so they don't linger forever.
|
||||||
|
let audioBases = Set(entries.filter { $0.pathExtension == "m4a" }
|
||||||
|
.map { $0.deletingPathExtension().lastPathComponent })
|
||||||
|
for sidecar in entries where sidecar.lastPathComponent.hasSuffix(".meta.plist") {
|
||||||
|
let base = sidecar.lastPathComponent.replacingOccurrences(of: ".meta.plist", with: "")
|
||||||
|
if !audioBases.contains(base) {
|
||||||
|
try? fm.removeItem(at: sidecar)
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshPendingCount()
|
refreshPendingCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ struct NotesWatchApp: App {
|
|||||||
RecordView()
|
RecordView()
|
||||||
}
|
}
|
||||||
.environment(services)
|
.environment(services)
|
||||||
.task { services.bootstrap() }
|
.task {
|
||||||
|
services.bootstrap()
|
||||||
|
#if DEBUG
|
||||||
|
// CLI-driven repro of the record button (simctl launch … autorecord).
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("autorecord") {
|
||||||
|
await services.startRecording()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
.onOpenURL { url in services.handleDeepLink(url) }
|
.onOpenURL { url in services.handleDeepLink(url) }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import IndieSync
|
import IndieSync
|
||||||
|
import OSLog
|
||||||
import WatchKit
|
import WatchKit
|
||||||
|
|
||||||
/// Records a single voice note on the watch into a persisted
|
/// Records a single voice note on the watch into a persisted
|
||||||
@@ -53,14 +54,16 @@ final class WatchAudioRecorder {
|
|||||||
/// Ask for microphone access, returning whether it was granted. Safe to call
|
/// Ask for microphone access, returning whether it was granted. Safe to call
|
||||||
/// every time recording starts — the system only prompts once.
|
/// every time recording starts — the system only prompts once.
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
await AVAudioApplication.requestRecordPermission()
|
let granted = await AVAudioApplication.requestRecordPermission()
|
||||||
|
permissionDenied = !granted
|
||||||
|
return granted
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the mic has been explicitly denied, so the UI can show a jump to
|
/// Whether the mic has been explicitly denied, so the UI can show a jump to
|
||||||
/// Settings instead of a dead record button.
|
/// Settings instead of a dead record button. Stored (not computed from
|
||||||
var permissionDenied: Bool {
|
/// `AVAudioApplication.shared.recordPermission`) so Observation tracks it and
|
||||||
AVAudioApplication.shared.recordPermission == .denied
|
/// the UI actually flips to the denied state the moment a request is refused.
|
||||||
}
|
private(set) var permissionDenied = AVAudioApplication.shared.recordPermission == .denied
|
||||||
|
|
||||||
/// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording.
|
/// Begin recording into a fresh `<ULID>.m4a`. A no-op if already recording.
|
||||||
/// Throws if the audio session or recorder can't be configured.
|
/// Throws if the audio session or recorder can't be configured.
|
||||||
@@ -111,6 +114,14 @@ final class WatchAudioRecorder {
|
|||||||
let recording = Recording(
|
let recording = Recording(
|
||||||
id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt)
|
id: current.id, url: current.url, duration: duration, recordedAt: current.startedAt)
|
||||||
teardown()
|
teardown()
|
||||||
|
// The watch simulator has no audio input device: record() reports
|
||||||
|
// success and currentTime advances, but no file is ever written. Don't
|
||||||
|
// hand a phantom recording up to the transfer queue.
|
||||||
|
guard FileManager.default.fileExists(atPath: recording.url.path) else {
|
||||||
|
Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
|
||||||
|
.warning("recording produced no file (simulator has no mic input?): \(recording.url.lastPathComponent, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return recording
|
return recording
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Composition root for the watch app. Owns the recorder and the transfer
|
/// Composition root for the watch app. Owns the recorder and the transfer
|
||||||
@@ -31,12 +32,21 @@ final class WatchAppServices {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "dev.rzen.indie.Notes.watchkitapp", category: "recording")
|
||||||
|
|
||||||
/// Request permission (once) then begin recording. A no-op if already
|
/// Request permission (once) then begin recording. A no-op if already
|
||||||
/// recording or permission is refused.
|
/// recording or permission is refused.
|
||||||
func startRecording() async {
|
func startRecording() async {
|
||||||
guard !recorder.isRecording else { return }
|
guard !recorder.isRecording else { return }
|
||||||
guard await recorder.requestPermission() else { return }
|
guard await recorder.requestPermission() else {
|
||||||
try? recorder.startRecording()
|
logger.warning("microphone permission refused")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try recorder.startRecording()
|
||||||
|
} catch {
|
||||||
|
logger.error("could not start recording: \(error, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopAndQueue() {
|
private func stopAndQueue() {
|
||||||
|
|||||||
Reference in New Issue
Block a user