diff --git a/SentryDeck.Data/Playback/EncryptedClipDetector.cs b/SentryDeck.Data/Playback/EncryptedClipDetector.cs
new file mode 100644
index 0000000..34965bd
--- /dev/null
+++ b/SentryDeck.Data/Playback/EncryptedClipDetector.cs
@@ -0,0 +1,88 @@
+using System.Text;
+using Serilog;
+
+namespace SentryDeck;
+
+///
+/// Heuristics for Tesla's encrypted dashcam recordings.
+/// Software update 2026.20 turns on "Encrypt Dashcam Recordings" by default (Controls > Safety), writing AES-encrypted containers to the USB drive instead of plain MP4s, so the files no longer begin with an ISO-BMFF box header.
+/// Decryption keys are only obtainable from Tesla's servers via the owner's account (dashcam.tesla.com), so the app can detect the state but not play it.
+///
+public static class EncryptedClipDetector
+{
+ ///
+ /// Top-level box types an unencrypted recording can plausibly start with.
+ /// Tesla files start with ftyp; the rest keep the sniff from misreporting other muxers' output — a file starting with any of these is ordinary video (playable or merely corrupt), not encrypted.
+ ///
+ private static readonly string[] KnownLeadingBoxTypes =
+ [
+ "ftyp",
+ "styp",
+ "moov",
+ "mdat",
+ "free",
+ "skip",
+ "wide",
+ "pdin",
+ "sidx",
+ "uuid",
+ ];
+
+ ///
+ /// True when the clip's front-camera files are all present with content but none starts like an MP4 — the signature of a drive written with encryption enabled.
+ /// A merely corrupt or truncated clip still has a valid ftyp header on at least some chunks, so it stays on the ordinary unreadable-file path.
+ ///
+ public static bool LooksEncrypted(CamClip clip)
+ {
+ if (clip is null || clip.Chunks.Count == 0)
+ {
+ return false;
+ }
+
+ var sawFrontFile = false;
+
+ foreach (var chunk in clip.Chunks)
+ {
+ if (!chunk.Files.TryGetValue(CameraNames.Front, out var frontFile))
+ {
+ continue;
+ }
+
+ sawFrontFile = true;
+
+ if (!LooksEncrypted(frontFile.FullPath))
+ {
+ return false;
+ }
+ }
+
+ return sawFrontFile;
+ }
+
+ ///
+ /// True when the file has content but does not start with a recognizable MP4 box.
+ ///
+ public static bool LooksEncrypted(string path)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+
+ Span header = stackalloc byte[8];
+ if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length)
+ {
+ // Shorter than one box header: a truncated write, not an encrypted container (those carry a fixed header plus at least one 4 KiB payload chunk).
+ return false;
+ }
+
+ var leadingBoxType = Encoding.ASCII.GetString(header[4..]);
+ return !KnownLeadingBoxTypes.Contains(leadingBoxType);
+ }
+ catch (Exception ex)
+ {
+ // Unreadable at the filesystem level (missing, locked, ...) is not an encryption signal; let the ordinary unreadable-file handling describe it.
+ Log.Debug(ex, "Could not sniff file header for encryption. File={File}", path);
+ return false;
+ }
+ }
+}
diff --git a/SentryDeck.Tests/EncryptedClipDetectorTests.cs b/SentryDeck.Tests/EncryptedClipDetectorTests.cs
new file mode 100644
index 0000000..f1ac222
--- /dev/null
+++ b/SentryDeck.Tests/EncryptedClipDetectorTests.cs
@@ -0,0 +1,100 @@
+using System.IO;
+
+namespace SentryDeck.Tests;
+
+public sealed class EncryptedClipDetectorTests : IDisposable
+{
+ private readonly string _root = Directory.CreateDirectory(
+ Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}")).FullName;
+
+ public void Dispose() => Directory.Delete(_root, recursive: true);
+
+ private string WriteFile(string name, byte[] bytes)
+ {
+ var path = Path.Combine(_root, name);
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ [Fact]
+ public void ValidMp4_IsNotEncrypted()
+ {
+ var path = WriteFile("valid.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60)));
+
+ EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void EncryptedLookingFile_IsEncrypted()
+ {
+ var path = WriteFile("encrypted.mp4", TestMp4.EncryptedLookingBytes);
+
+ EncryptedClipDetector.LooksEncrypted(path).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void TruncatedTinyFile_IsNotEncrypted()
+ {
+ // Shorter than one box header: a power-loss truncation, not an encrypted container.
+ var path = WriteFile("truncated.mp4", [0x00, 0x00, 0x01]);
+
+ EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TruncatedButValidHeader_IsNotEncrypted()
+ {
+ // A recording cut off mid-write still starts with its ftyp box; that's the corrupt path, not the encrypted one.
+ var valid = TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60));
+ var path = WriteFile("cutoff.mp4", valid[..12]);
+
+ EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void MissingFile_IsNotEncrypted()
+ {
+ EncryptedClipDetector.LooksEncrypted(Path.Combine(_root, "nope.mp4")).ShouldBeFalse();
+ }
+
+ private CamClip ClipWithFrontFiles(params byte[][] frontFileContents)
+ {
+ var start = new DateTime(2026, 7, 9, 10, 0, 0);
+ var chunks = frontFileContents.Select((bytes, index) =>
+ {
+ var timestamp = start.AddMinutes(index);
+ var path = WriteFile($"{timestamp:yyyy-MM-dd_HH-mm-ss}-front.mp4", bytes);
+ return new CamChunk(timestamp, [new CamFile(path, timestamp, CameraNames.Front)]);
+ }).ToList();
+
+ return new CamClip(_root, "Clip", start, chunks, camEvent: null);
+ }
+
+ [Fact]
+ public void Clip_WithAllFrontFilesEncrypted_IsEncrypted()
+ {
+ var clip = ClipWithFrontFiles(TestMp4.EncryptedLookingBytes, TestMp4.EncryptedLookingBytes);
+
+ EncryptedClipDetector.LooksEncrypted(clip).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Clip_WithOnePlayableChunk_IsNotEncrypted()
+ {
+ // One valid header among the chunks means ordinary video with some corruption — the recovery path should keep handling it.
+ var clip = ClipWithFrontFiles(
+ TestMp4.EncryptedLookingBytes,
+ TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60)));
+
+ EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Clip_WithNoChunks_IsNotEncrypted()
+ {
+ var clip = new CamClip(_root, "Empty", new DateTime(2026, 7, 9), [], camEvent: null);
+
+ EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse();
+ EncryptedClipDetector.LooksEncrypted((CamClip)null).ShouldBeFalse();
+ }
+}
diff --git a/SentryDeck.Tests/Fixtures/TestMp4.cs b/SentryDeck.Tests/Fixtures/TestMp4.cs
index 780b646..087755d 100644
--- a/SentryDeck.Tests/Fixtures/TestMp4.cs
+++ b/SentryDeck.Tests/Fixtures/TestMp4.cs
@@ -15,6 +15,23 @@ internal static class TestMp4
///
public static byte[] GarbageBytes => [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09];
+ ///
+ /// Bytes shaped like Tesla's 2026.20 encrypted recordings: a 20-byte header (with an embedded UUID) followed by an IV-prefixed 4 KiB ciphertext chunk — no MP4 box structure anywhere.
+ ///
+ public static byte[] EncryptedLookingBytes
+ {
+ get
+ {
+ var bytes = new byte[20 + 16 + 4096];
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ bytes[i] = (byte)(i * 197 + 13); // deterministic pseudo-noise, never ASCII "ftyp"
+ }
+
+ return bytes;
+ }
+ }
+
///
/// A minimal valid mp4 whose mvhd encodes the given duration (version 0, millisecond timescale).
///
diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs
index 285c3e6..813f942 100644
--- a/SentryDeck.Tests/VideoPlayerControllerTests.cs
+++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs
@@ -94,6 +94,57 @@ public async Task SelectingClip_WhenFrontFileMissing_ReportsOpenFailure()
front.OpenedPaths.ShouldBeEmpty();
}
+ [Fact]
+ public async Task SelectingClip_WhenAllFilesAreEncrypted_ExplainsTheEncryptionToggle()
+ {
+ // A drive written by Tesla software 2026.20+ with "Encrypt Dashcam Recordings" on: every file exists but none is a playable MP4.
+ // The real builder probes and excludes every chunk, and the error must point at the encryption toggle, not claim missing footage.
+ using var clipFiles = TestClipFiles.Create(chunkCount: 2);
+ foreach (var chunk in clipFiles.Clip.Chunks)
+ {
+ foreach (var file in chunk.Files.Values)
+ {
+ File.WriteAllBytes(file.FullPath, TestMp4.EncryptedLookingBytes);
+ }
+ }
+
+ var front = new FakeCameraPlayer();
+ using var controller = CreateController(front, mediaSourceBuilder: new FfconcatMediaSourceBuilder());
+
+ controller.LoadClips([clipFiles.Clip]);
+ controller.Playlist.MoveTo(0);
+
+ await WaitUntilAsync(() => controller.ErrorMessage is not null);
+
+ controller.ErrorMessage.ShouldBe(VideoPlayerController.EncryptedClipMessage);
+ controller.ErrorMessage.ShouldContain("Encrypt Dashcam Recordings");
+ controller.IsPlaying.ShouldBeFalse();
+ front.OpenedPaths.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task SelectingClip_WhenAllFilesAreGarbage_ButNotEncrypted_KeepsTheCorruptMessage()
+ {
+ // Same all-unreadable shape, but the files still carry MP4 headers (truncated writes): that's ordinary corruption and must NOT be blamed on encryption.
+ using var clipFiles = TestClipFiles.Create(chunkCount: 1);
+ var truncated = TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))[..12];
+ foreach (var file in clipFiles.Clip.Chunks[0].Files.Values)
+ {
+ File.WriteAllBytes(file.FullPath, truncated);
+ }
+
+ var front = new FakeCameraPlayer();
+ using var controller = CreateController(front, mediaSourceBuilder: new FfconcatMediaSourceBuilder());
+
+ controller.LoadClips([clipFiles.Clip]);
+ controller.Playlist.MoveTo(0);
+
+ await WaitUntilAsync(() => controller.ErrorMessage is not null);
+
+ controller.ErrorMessage.ShouldBe("No front camera footage found.");
+ controller.IsPlaying.ShouldBeFalse();
+ }
+
[Fact]
public async Task PauseSeekAndStop_ControlOpenPlayers()
{
diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs
index c68c127..4e11483 100644
--- a/SentryDeck/Playback/VideoPlayerController.cs
+++ b/SentryDeck/Playback/VideoPlayerController.cs
@@ -22,6 +22,13 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl
private const int MaxRecoveryAttemptsPerClip = 3;
+ ///
+ /// Shown when a clip's files carry Tesla's 2026.20+ dashcam encryption instead of plain MP4s.
+ /// The keys live behind the owner's Tesla account, so pointing at the in-car toggle and Tesla's own viewer is the most useful thing the app can do.
+ ///
+ internal const string EncryptedClipMessage =
+ "This clip appears to be encrypted by the vehicle (Tesla software 2026.20 and later encrypts dashcam recordings by default). To record playable clips, turn off Controls > Safety > Encrypt Dashcam Recordings. Already-encrypted clips can be viewed at dashcam.tesla.com.";
+
///
/// One-shot guard for the reopen/seek race after a recovery: how long to wait after issuing
/// the resume seek before verifying the front player actually landed near the target, and how
@@ -614,7 +621,11 @@ private async Task OpenClipInternalAsync(
clip.FullPath,
mediaSource.CameraPlaylistPaths.Keys.Order().ToArray(),
requestId);
- ErrorMessage = $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found.";
+
+ // A fully encrypted clip lands here too: the builder probes every chunk's front file, finds no readable moov in any of them, and excludes them all — indistinguishable from "no footage" without sniffing the files themselves.
+ ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip)
+ ? EncryptedClipMessage
+ : $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found.";
return;
}
@@ -1165,7 +1176,9 @@ private void GiveUpOnClip(CamClip clip, int badChunkIndex)
clip.FullPath,
badChunkIndex,
_recoveryAttempts);
- ErrorMessage = "Playback stopped: too many unreadable video files.";
+ ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip)
+ ? EncryptedClipMessage
+ : "Playback stopped: too many unreadable video files.";
IsMediaOpen = false;
}