Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
31 changes: 23 additions & 8 deletions Sources/DashcamOffloaderApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ struct ContentView: View {
} label: {
Label("Review & Submit", systemImage: "graduationcap")
}
.disabled(!viewModel.scanSummary.hasScan)
.disabled(viewModel.isScanning || !viewModel.scanSummary.hasScan)
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1492,15 +1496,15 @@ 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")
}
.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)
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)")
Expand Down Expand Up @@ -1628,6 +1637,7 @@ struct CardLearningSheet: View {
.onAppear {
viewModel.feedbackMessage = ""
scanPreview = viewModel.makeFeedbackScanSnapshot()
previewScanRevision = viewModel.scanRevision
prefillFromScanIfNeeded()
}
}
Expand Down Expand Up @@ -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),
Expand All @@ -1731,6 +1745,7 @@ struct CardLearningSheet: View {
contact: contact,
includeScan: true,
scanSnapshot: scanPreview,
scanRevisionAtPreview: previewScanRevision,
training: training,
successMessage: "Learning package submitted successfully.",
onSuccess: {
Expand Down
2 changes: 1 addition & 1 deletion Sources/DashcamOffloaderApp/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)] {
Expand Down
36 changes: 31 additions & 5 deletions Sources/DashcamOffloaderApp/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -813,6 +814,7 @@ final class TransferViewModel: ObservableObject {

private func clearSourceDerivedState(for source: MountedSource?) {
scanGeneration += 1
scanRevision += 1
profileSelectionRevision += 1
isScanning = false
copyProgress = CopyProgress()
Expand Down Expand Up @@ -856,6 +858,7 @@ final class TransferViewModel: ObservableObject {
}

scanGeneration += 1
scanRevision += 1
let generation = scanGeneration
let profileRevisionAtStart = profileSelectionRevision
isScanning = true
Expand Down Expand Up @@ -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
Expand All @@ -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..."
Expand All @@ -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
Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion Sources/DashcamOffloaderApp/VerificationTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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" }),
Expand Down
9 changes: 5 additions & 4 deletions docs/releases/latest.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions scripts/build-macos-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.27</string>
<string>0.1.28</string>
<key>CFBundleVersion</key>
<string>27</string>
<string>28</string>
<key>DashcamOffloaderBuildCommit</key>
<string>$BUILD_COMMIT</string>
<key>LSMinimumSystemVersion</key>
Expand Down
Loading