From 6a16081418ce7558378357c7666069666692e971 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:10:14 -0400 Subject: [PATCH 1/3] Persist explicit recording start timestamps and use them for recording dates --- ...AudioRecorderViewModel+Interruptions.swift | 20 +++------- .../AudioRecorderViewModel+Segments.swift | 3 +- .../AudioRecorderViewModel+Utilities.swift | 38 ++++++++++++++++++- .../ViewModels/AudioRecorderViewModel.swift | 7 +++- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift index 88b8b18..59f6634 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift @@ -424,18 +424,7 @@ extension AudioRecorderViewModel { let originalFilename = currentURL.deletingPathExtension().lastPathComponent let duration = getRecordingDuration(url: currentURL) - // Get file creation date, or use current date as fallback - let recordingDate: Date - do { - let attributes = try FileManager.default.attributesOfItem(atPath: currentURL.path) - if let creationDate = attributes[.creationDate] as? Date { - recordingDate = creationDate - } else { - recordingDate = Date() - } - } catch { - recordingDate = Date() - } + let recordingDate = currentRecordingDate(for: currentURL) _ = workflowManager.createRecording( url: currentURL, @@ -446,6 +435,7 @@ extension AudioRecorderViewModel { quality: quality, locationData: recordingStartLocationData ) + recordingStartedAt = nil } } } @@ -583,7 +573,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -598,6 +588,7 @@ extension AudioRecorderViewModel { // Reset processing flag recordingBeingProcessed = false resetRecordingLocation() + recordingStartedAt = nil // End background task after successful recovery and save endBackgroundTask() @@ -708,7 +699,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -724,6 +715,7 @@ extension AudioRecorderViewModel { self.recordingURL = nil self.recordingBeingProcessed = false self.resetRecordingLocation() + self.recordingStartedAt = nil } // Send notification to user about recovery (with slight delay to improve visibility) diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift index f87e774..03c7b05 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift @@ -127,7 +127,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: mainURL, name: displayName, - date: Date(), + date: currentRecordingDate(for: mainURL), fileSize: fileSize, duration: duration, quality: quality, @@ -137,6 +137,7 @@ extension AudioRecorderViewModel { AppLog.shared.recording("Merged recording created with workflow manager, ID: \(recordingId)") self.resetRecordingLocation() + self.recordingStartedAt = nil } else { AppLog.shared.recording("WorkflowManager not set - merged recording not saved to database", level: .error) } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 5027c2e..619c9da 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -9,6 +9,10 @@ import Foundation @preconcurrency import AVFoundation import UserNotifications +private struct RecordingTimestampMetadata: Codable { + let recordedAt: Date +} + // MARK: - AVAudioRecorderDelegate extension AudioRecorderViewModel: AVAudioRecorderDelegate { @@ -77,7 +81,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate { let recordingId = workflowManager.createRecording( url: recordingURL, name: displayName, - date: Date(), + date: currentRecordingDate(for: recordingURL), fileSize: fileSize, duration: duration, quality: quality, @@ -88,6 +92,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate { // Watch audio integration removed self.resetRecordingLocation() + self.recordingStartedAt = nil } else { AppLog.shared.recording("WorkflowManager not set - recording not saved to database", level: .error) } @@ -276,6 +281,37 @@ extension AudioRecorderViewModel { // Final fallback to the timer value we tracked during recording return recordingTime } + + func currentRecordingDate(for url: URL?) -> Date { + if let recordingStartedAt { + return recordingStartedAt + } + + if let url, + let data = try? Data(contentsOf: recordingTimestampMetadataURL(for: url)), + let metadata = try? JSONDecoder().decode(RecordingTimestampMetadata.self, from: data) { + return metadata.recordedAt + } + + return Date() + } + + func persistRecordingCapturedAt(_ date: Date, for url: URL) { + let metadata = RecordingTimestampMetadata(recordedAt: date) + guard let data = try? JSONEncoder().encode(metadata) else { + return + } + + do { + try data.write(to: recordingTimestampMetadataURL(for: url), options: .atomic) + } catch { + AppLog.shared.recording("Failed to write recording timestamp metadata: \(error.localizedDescription)", level: .error) + } + } + + private func recordingTimestampMetadataURL(for recordingURL: URL) -> URL { + recordingURL.deletingPathExtension().appendingPathExtension("recordingmeta") + } } // MARK: - Naming Convention diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift index df7e8f9..0059a0f 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift @@ -82,6 +82,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { // Track last checkpoint time for periodic data flushing var lastCheckpointTime: Date = Date.distantPast + var recordingStartedAt: Date? let checkpointInterval: TimeInterval = 30.0 // Try to checkpoint every 30 seconds let forceCheckpointInterval: TimeInterval = 90.0 // Force checkpoint after 90 seconds even without silence @@ -454,7 +455,10 @@ class AudioRecorderViewModel: NSObject, ObservableObject { func setupRecording() { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioFilename = documentsPath.appendingPathComponent(generateAppRecordingFilename()) + let recordingStartDate = Date() recordingURL = audioFilename + recordingStartedAt = recordingStartDate + persistRecordingCapturedAt(recordingStartDate, for: audioFilename) // Initialize segment tracking for this new recording mainRecordingURL = audioFilename @@ -600,7 +604,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -630,6 +634,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { } resetRecordingLocation() + recordingStartedAt = nil endBackgroundTask() } From eaf90b7d12477d1ec6152b10f870880676ffa598 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:43:35 -0400 Subject: [PATCH 2/3] Clean up .recordingmeta sidecars on deletion and fix display name timestamp Address review feedback: the new .recordingmeta sidecar files were not being cleaned up when recordings were deleted, causing silent storage growth. Add cleanup to all four deletion paths (EnhancedFileManager, TranscriptViews, RecordingArchiveService, orphan cleanup). Also fix generateAppRecordingDisplayName() to use recordingStartedAt so the display name timestamp matches the persisted recordingDate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BisonNotes AI/EnhancedFileManager.swift | 30 +++++++++++-------- .../Models/RecordingArchiveService.swift | 5 ++++ .../AudioRecorderViewModel+Utilities.swift | 2 +- .../BisonNotes AI/Views/TranscriptViews.swift | 8 +++-- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift index c1c142f..50ecdc9 100644 --- a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift +++ b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift @@ -363,18 +363,19 @@ class EnhancedFileManager: ObservableObject { } } - // Delete associated location file if it exists - let locationURL = normalizedURL.deletingPathExtension().appendingPathExtension("location") - if FileManager.default.fileExists(atPath: locationURL.path) { - do { - try FileManager.default.removeItem(at: locationURL) - AppLog.shared.fileManagement("Deleted location file: \(locationURL.lastPathComponent)") - } catch { - if error.isThumbnailGenerationError { - AppLog.shared.fileManagement("Thumbnail generation warning during location file deletion: \(error.localizedDescription)", level: .debug) - // Continue with deletion even if thumbnail generation fails - } else { - throw error + // Delete associated sidecar files if they exist + for ext in ["location", "recordingmeta"] { + let sidecarURL = normalizedURL.deletingPathExtension().appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: sidecarURL.path) { + do { + try FileManager.default.removeItem(at: sidecarURL) + AppLog.shared.fileManagement("Deleted \(ext) file: \(sidecarURL.lastPathComponent)") + } catch { + if error.isThumbnailGenerationError { + AppLog.shared.fileManagement("Thumbnail generation warning during \(ext) file deletion: \(error.localizedDescription)", level: .debug) + } else { + throw error + } } } } @@ -669,6 +670,11 @@ class EnhancedFileManager: ObservableObject { if !dryRun { try FileManager.default.removeItem(at: file) + // Also remove sidecar files for the orphaned audio + for ext in ["location", "recordingmeta"] { + let sidecarURL = file.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } AppLog.shared.fileManagement("Deleted orphaned file: \(file.lastPathComponent) (\(fileSize) bytes)") deletedCount += 1 } else { diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index 3355321..6449582 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -122,6 +122,11 @@ class RecordingArchiveService: ObservableObject { } catch { AppLog.shared.recording("Archived: failed to remove local audio: \(error.localizedDescription)", level: .error) } + // Clean up sidecar files alongside the audio + for ext in ["location", "recordingmeta"] { + let sidecarURL = url.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } } } } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 619c9da..5c994f6 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -328,7 +328,7 @@ extension AudioRecorderViewModel { func generateAppRecordingDisplayName() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let timestamp = formatter.string(from: Date()) + let timestamp = formatter.string(from: recordingStartedAt ?? Date()) return "apprecording-\(timestamp)" } diff --git a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift index d09335d..01ba47a 100644 --- a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift +++ b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift @@ -713,9 +713,11 @@ struct TranscriptsView: View { // Delete the associated dummy audio file if it exists if let recordingURL = appCoordinator.getAbsoluteURL(for: importedTranscript.recording) { try? FileManager.default.removeItem(at: recordingURL) - // Delete associated location file if present - let locationURL = recordingURL.deletingPathExtension().appendingPathExtension("location") - try? FileManager.default.removeItem(at: locationURL) + // Delete associated sidecar files if present + for ext in ["location", "recordingmeta"] { + let sidecarURL = recordingURL.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } AppLog.shared.transcription("Deleted dummy audio file: \(recordingURL.lastPathComponent)", level: .debug) } From b6c66b63bb28de9b9a2ef7ae777ea1bc5370a09a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:11:59 +0000 Subject: [PATCH 3/3] Scope recordingStartedAt timestamp to its recording URL Change recordingStartedAt from Date? to (url: URL, date: Date)? so currentRecordingDate(for:) only returns the in-memory start time when the provided URL matches the active recording URL. Previously, an async merge that completed after a new recording started would pick up the new session's timestamp for the old merged file, corrupting its recordingDate. Co-authored-by: Tim Champ --- .../ViewModels/AudioRecorderViewModel+Utilities.swift | 6 +++--- .../BisonNotes AI/ViewModels/AudioRecorderViewModel.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 5c994f6..ec345bc 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -283,8 +283,8 @@ extension AudioRecorderViewModel { } func currentRecordingDate(for url: URL?) -> Date { - if let recordingStartedAt { - return recordingStartedAt + if let entry = recordingStartedAt, let url, entry.url == url { + return entry.date } if let url, @@ -328,7 +328,7 @@ extension AudioRecorderViewModel { func generateAppRecordingDisplayName() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let timestamp = formatter.string(from: recordingStartedAt ?? Date()) + let timestamp = formatter.string(from: recordingStartedAt?.date ?? Date()) return "apprecording-\(timestamp)" } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift index 0059a0f..348f7ea 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift @@ -82,7 +82,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { // Track last checkpoint time for periodic data flushing var lastCheckpointTime: Date = Date.distantPast - var recordingStartedAt: Date? + var recordingStartedAt: (url: URL, date: Date)? let checkpointInterval: TimeInterval = 30.0 // Try to checkpoint every 30 seconds let forceCheckpointInterval: TimeInterval = 90.0 // Force checkpoint after 90 seconds even without silence @@ -457,7 +457,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { let audioFilename = documentsPath.appendingPathComponent(generateAppRecordingFilename()) let recordingStartDate = Date() recordingURL = audioFilename - recordingStartedAt = recordingStartDate + recordingStartedAt = (url: audioFilename, date: recordingStartDate) persistRecordingCapturedAt(recordingStartDate, for: audioFilename) // Initialize segment tracking for this new recording