diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ae8aa7..1f178df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: id: manifest env: UPDATE_BASE_URL: ${{ vars.DASHCAM_OFFLOADER_UPDATE_BASE_URL || 'https://dashcam-offloader-updates.vortexradar.workers.dev' }} - VERSION: 0.1.27 + VERSION: 0.1.28 ZIP_NAME: ${{ steps.package.outputs.zip_name }} ZIP_SHA256: ${{ steps.package.outputs.sha256 }} SHORT_SHA: ${{ steps.package.outputs.short_sha }} diff --git a/Sources/DashcamOffloaderApp/ContentView.swift b/Sources/DashcamOffloaderApp/ContentView.swift index 7173df7..b1088c3 100644 --- a/Sources/DashcamOffloaderApp/ContentView.swift +++ b/Sources/DashcamOffloaderApp/ContentView.swift @@ -423,7 +423,7 @@ struct ContentView: View { } label: { Label("Review & Submit", systemImage: "graduationcap") } - .disabled(!viewModel.scanSummary.hasScan) + .disabled(viewModel.isScanning || !viewModel.scanSummary.hasScan) } } } @@ -1457,9 +1457,13 @@ struct CardLearningSheet: View { @State private var notes = "" @State private var contact = "" @State private var scanPreview: FeedbackScanSnapshot? + @State private var previewScanRevision = -1 private var canSubmit: Bool { - viewModel.scanSummary.hasScan && + !viewModel.isScanning && viewModel.scanSummary.hasScan && + scanPreview != nil && previewScanRevision == viewModel.scanRevision && + (!viewModel.eligibleClips.contains(where: \.isVideo) || + scanPreview?.videoSpecSummaries.contains(where: { $0.sampleBitrateMin != nil }) == true) && !manufacturer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !viewModel.isSubmittingFeedback @@ -1492,7 +1496,7 @@ struct CardLearningSheet: View { .buttonStyle(.borderless) } - if viewModel.scanSummary.hasScan { + if !viewModel.isScanning && viewModel.scanSummary.hasScan { HStack(spacing: 14) { Label("Card scan ready", systemImage: "checkmark.circle") Label(viewModel.selectedProfile?.displayName ?? "No profile match", systemImage: "camera") @@ -1500,7 +1504,7 @@ struct CardLearningSheet: View { .font(.caption) .foregroundStyle(.secondary) } else { - Text("Scan the card first, then submit the learning package.") + Text(viewModel.isScanning ? "Scanning card; wait for completion before submitting." : "Scan the card first, then submit the learning package.") .font(.caption) .foregroundStyle(.secondary) } @@ -1572,6 +1576,12 @@ struct CardLearningSheet: View { .foregroundStyle(.secondary) if let scanPreview { + if viewModel.eligibleClips.contains(where: \.isVideo) && + !scanPreview.videoSpecSummaries.contains(where: { $0.sampleBitrateMin != nil }) { + Label("No video bitrate was measured. Check that the card is readable and rescan before submitting.", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } DisclosureGroup("Review privacy-safe scan summary") { ScrollView { VStack(alignment: .leading, spacing: 7) { @@ -1581,12 +1591,11 @@ struct CardLearningSheet: View { if !scanPreview.videoSpecSummaries.isEmpty { Text("Measured video groups") .fontWeight(.semibold) - ForEach(Array(scanPreview.videoSpecSummaries.prefix(8).enumerated()), id: \.offset) { _, summary in + Text("Ranges reflect sampled clips in each folder/mode/channel, not one setting for the whole card. Label each test in notes if modes share a folder.") + .foregroundStyle(.secondary) + ForEach(Array(scanPreview.videoSpecSummaries.enumerated()), id: \.offset) { _, summary in Text(learningVideoSummary(summary)) } - if scanPreview.videoSpecSummaries.count > 8 { - Text("+ \(scanPreview.videoSpecSummaries.count - 8) more measured groups") - } } if !scanPreview.settingSnapshots.isEmpty { Text("\(scanPreview.settingSnapshots.count) allowlisted model/settings summary item(s)") @@ -1628,6 +1637,7 @@ struct CardLearningSheet: View { .onAppear { viewModel.feedbackMessage = "" scanPreview = viewModel.makeFeedbackScanSnapshot() + previewScanRevision = viewModel.scanRevision prefillFromScanIfNeeded() } } @@ -1710,6 +1720,10 @@ struct CardLearningSheet: View { } private func submitLearningPackage() { + guard canSubmit else { + viewModel.feedbackMessage = "Card scan is incomplete or has changed. Finish scanning and reopen this window." + return + } let training = CardTrainingDetails( manufacturer: manufacturer.trimmingCharacters(in: .whitespacesAndNewlines), model: model.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1731,6 +1745,7 @@ struct CardLearningSheet: View { contact: contact, includeScan: true, scanSnapshot: scanPreview, + scanRevisionAtPreview: previewScanRevision, training: training, successMessage: "Learning package submitted successfully.", onSuccess: { diff --git a/Sources/DashcamOffloaderApp/Models.swift b/Sources/DashcamOffloaderApp/Models.swift index 74c3712..7e0f5f2 100644 --- a/Sources/DashcamOffloaderApp/Models.swift +++ b/Sources/DashcamOffloaderApp/Models.swift @@ -832,7 +832,7 @@ struct ScanSummary: Hashable, Sendable { var modeCounts: [String: Int] = [:] var hasScan: Bool { - !sourcePath.isEmpty + !sourcePath.isEmpty && scannedFiles > 0 } var sortedCategoryCounts: [(String, Int)] { diff --git a/Sources/DashcamOffloaderApp/TransferViewModel.swift b/Sources/DashcamOffloaderApp/TransferViewModel.swift index a6f8867..ccd4c3e 100644 --- a/Sources/DashcamOffloaderApp/TransferViewModel.swift +++ b/Sources/DashcamOffloaderApp/TransferViewModel.swift @@ -20,6 +20,7 @@ final class TransferViewModel: ObservableObject { @Published var cardHealthReport = CardHealthReport() @Published var statusMessage = "Ready" @Published var isScanning = false + @Published private(set) var scanRevision = 0 @Published var isCheckingCardHealth = false @Published var showAllVolumes = false @Published var copyResults: [CopyPlanItem] = [] @@ -813,6 +814,7 @@ final class TransferViewModel: ObservableObject { private func clearSourceDerivedState(for source: MountedSource?) { scanGeneration += 1 + scanRevision += 1 profileSelectionRevision += 1 isScanning = false copyProgress = CopyProgress() @@ -856,6 +858,7 @@ final class TransferViewModel: ObservableObject { } scanGeneration += 1 + scanRevision += 1 let generation = scanGeneration let profileRevisionAtStart = profileSelectionRevision isScanning = true @@ -1357,6 +1360,7 @@ final class TransferViewModel: ObservableObject { contact: String, includeScan: Bool, scanSnapshot: FeedbackScanSnapshot? = nil, + scanRevisionAtPreview: Int? = nil, training: CardTrainingDetails? = nil, successMessage: String = "Feedback submitted successfully.", onSuccess: (@MainActor () -> Void)? = nil @@ -1366,6 +1370,27 @@ final class TransferViewModel: ObservableObject { feedbackMessage = "Add a short note before submitting" return } + // A selected source path is not evidence that a scan completed. Refuse + // stale previews and missing metadata instead of submitting folder-only data. + let submittedScan: FeedbackScanSnapshot? + if kind == .training || includeScan { + guard !isScanning, scanSummary.hasScan, + scanRevisionAtPreview == nil || scanRevisionAtPreview == scanRevision, + let currentScan = makeFeedbackScanSnapshot(), + currentScan.scannedFiles > 0, currentScan.copyableItems > 0, + scanSnapshot == nil || scanSnapshot == currentScan else { + feedbackMessage = "Card scan is incomplete or has changed. Finish scanning and reopen the submission window." + return + } + if kind == .training && eligibleClips.contains(where: \.isVideo) && + !currentScan.videoSpecSummaries.contains(where: { $0.sampleBitrateMin != nil }) { + feedbackMessage = "No video bitrate could be measured. Check the card and rescan before submitting." + return + } + submittedScan = currentScan + } else { + submittedScan = nil + } isSubmittingFeedback = true feedbackMessage = "Submitting feedback..." @@ -1377,10 +1402,9 @@ final class TransferViewModel: ObservableObject { appVersion: appVersionString(), createdAt: ISO8601DateFormatter().string(from: Date()), training: training, - // The learning window has already built the user-reviewed snapshot. - // Reusing it avoids reopening and inspecting the card a second time - // when the user presses Submit. - scan: includeScan ? (scanSnapshot ?? makeFeedbackScanSnapshot()) : nil + // Rebuild and compare with the reviewed preview to reject a changed + // card or stale learning sheet before the network request begins. + scan: submittedScan ) Task { [weak self, feedbackService] in @@ -1397,7 +1421,9 @@ final class TransferViewModel: ObservableObject { } func makeFeedbackScanSnapshot() -> FeedbackScanSnapshot? { - guard scanSummary.hasScan else { return nil } + guard !isScanning, scanSummary.hasScan, + selectedSource?.url.path == scanSummary.sourcePath, + lastScannedFiles.count == scanSummary.scannedFiles else { return nil } let requestedSourceRoot = selectedSource?.url let sourceRoot = lastEffectiveScanSourceURL ?? requestedSourceRoot diff --git a/Sources/DashcamOffloaderApp/VerificationTest.swift b/Sources/DashcamOffloaderApp/VerificationTest.swift index 920a866..c52cfb1 100644 --- a/Sources/DashcamOffloaderApp/VerificationTest.swift +++ b/Sources/DashcamOffloaderApp/VerificationTest.swift @@ -3,6 +3,11 @@ import Foundation enum VerificationTest { static func run() -> Bool { do { + guard ScanSummary(sourcePath: "/Volumes/CARD").hasScan == false, + ScanSummary(sourcePath: "/Volumes/CARD", scannedFiles: 1).hasScan else { + print("VERIFY FAIL: a selected card path was treated as a completed file scan") + return false + } // Older settings dominate the card, then two complete recordings // at a new setting are followed by a short stopped recording. var samplingClips: [ClipItem] = [] @@ -2456,7 +2461,22 @@ enum VerificationTest { let model = TransferViewModel() model.profiles = profiles model.loadScanResultForVerification(source: MountedSource(url: t340Source, name: "Private Card"), scanResult: t340Scan) - return model.makeFeedbackScanSnapshot() + let completed = model.makeFeedbackScanSnapshot() + // A rescanning sheet must not reuse a previously valid preview. + model.isScanning = true + guard model.makeFeedbackScanSnapshot() == nil else { return nil } + model.submitFeedback(kind: .training, message: "Test", contact: "", includeScan: true, + scanSnapshot: completed, scanRevisionAtPreview: model.scanRevision, + training: CardTrainingDetails(manufacturer: "VIOFO", model: "T340", channelSetup: "4CH", notes: "")) + guard model.feedbackMessage.contains("incomplete"), !model.isSubmittingFeedback else { return nil } + model.isScanning = false + // Video-shaped placeholders still cannot be submitted as a + // bitrate test if AVFoundation cannot read any video tracks. + model.submitFeedback(kind: .training, message: "Test", contact: "", includeScan: true, + scanSnapshot: completed, scanRevisionAtPreview: model.scanRevision, + training: CardTrainingDetails(manufacturer: "VIOFO", model: "T340", channelSetup: "4CH", notes: "")) + guard model.feedbackMessage.contains("No video bitrate"), !model.isSubmittingFeedback else { return nil } + return completed } guard let t340Learning, t340Learning.directorySummaries.contains(where: { $0.path == "DCIM/Movie/RO" }), diff --git a/docs/releases/latest.md b/docs/releases/latest.md index c2b56d5..caf987f 100644 --- a/docs/releases/latest.md +++ b/docs/releases/latest.md @@ -1,5 +1,6 @@ -# Dashcam Offloader 0.1.27 +# Dashcam Offloader 0.1.28 -- Keep unreleased camera evidence out of the public profile library and application package. -- Superseded updater archives are retained privately for rollback, no longer served by old download links. -- Existing released camera profiles and generic card downloading remain available. Source cards remain unchanged. +- Prevent a card-learning submission before a complete, nonempty file scan, or after a card rescan changes the reviewed data. +- Refuse learning submissions with unreadable video bitrate metadata rather than silently sending only folder counts. Show an actionable warning in the review window. +- Show every measured video group in the review window; bitrate ranges are samples by folder, recording mode, and camera channel, not one setting assumed for the entire card. +- Scanning remains read-only. Existing footage can be rescanned without formatting the card. diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 496bfa9..2dab77f 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -53,9 +53,9 @@ cat > "$CONTENTS_DIR/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - 0.1.27 + 0.1.28 CFBundleVersion - 27 + 28 DashcamOffloaderBuildCommit $BUILD_COMMIT LSMinimumSystemVersion