Skip to content

Release 1.4.0: encrypted video, alphanumeric PINs - #24

Open
cwbooth5 wants to merge 130 commits into
mainfrom
video
Open

cwbooth5 wants to merge 130 commits into
mainfrom
video

Conversation

@cwbooth5

@cwbooth5 cwbooth5 commented May 31, 2026 •

Copy link
Copy Markdown
Collaborator

Stack (merge top-down: #25 into video first, then #24 into main)

  1. Release 1.4.0: encrypted video, alphanumeric PINs #24 video → main: Release 1.4.0
  2. Fix CodeQL Swift build on macos-26 runners #25 fix/codeql-xcode-26 → video: CodeQL Swift build fix

Summary

Release 1.4.0. Merging this to main and tagging v1.4.0 triggers the App Store pipeline (see docs/HOW-TO-RELEASE.md).

  • Encrypted video: recording, encrypted storage and playback, thumbnails, video info sheet (location and creation date), decoy video support
  • Alphanumeric PINs: optional alphanumeric PINs for both the main PIN and the poison pill PIN, with strength rules and a revealable entry field
  • Gallery: landscape support, ordering fix, decrypted thumbnails cleared on lock and on delete
  • Camera: max-resolution stills matched to the preview's aspect ratio
  • Location: precision setting for location tags
  • Security/infra: SecureImageRepository split into an actor plus data sources, 5 minute session timeout, GitHub Actions pinned to commit checksums, Ruby dependency CVE fixes
  • Docs: HOW-TO-RELEASE.md now covers the full merge, tag, and App Store Connect flow

Test plan

  • Full suite on iPhone 17 Pro simulator, iOS 26.5: 230 passed, 0 failed, 7 skipped (the hardware-only tests)
  • Hardware tests on a physical iPhone, iOS 26.7.1: 8 passed, 0 skipped (Secure Enclave PIN binding, file protection)
  • Release configuration builds
  • App Store review readiness audit: no blocking issues
  • "iOS Build and Test" check passes on this PR

🤖 Generated with Claude Code

cwbooth5 and others added 30 commits February 2, 2026 00:13
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…() modifier

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
SecureGalleryView called both onDismiss?() and the environment dismiss()
at every dismissal site. When the gallery is a pushed nav destination
(Camera -> Gallery -> Select for Decoys), onDismiss is nav.navigateBack(),
so Save popped the stack twice -- removing .gallery then .camera -- landing
on the empty Color.clear root (black screen).

Call exactly one mechanism: the injected onDismiss when present, otherwise
the environment dismiss(). Fixes empty-gallery, decoy Back, and decoy Save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Videos are stored in a separate "videos" directory, but activatePoisonPill()
only wiped the photo gallery and thumbnails via deleteNonDecoyImages(). All
videos therefore survived the poison pill -- a serious data-leak that defeats
the feature's purpose.

Add deleteNonDecoyVideos(), invoked before deleteNonDecoyImages() (which removes
the decoy directory used for the decoy check). A video is preserved only if a
file with the same name exists in the decoy directory; since decoy selection is
photo-only today, every video is destroyed -- while remaining forward-compatible
if video decoys are added later.

Tests: PoisonPillVideoDeletionTests covers both the destroy and decoy-preserve
paths. To make them run, the previously orphaned FakeEncryptionScheme /
FakeThumbnailCache test helpers were added to the SnapSafeTests target (and
FakeEncryptionScheme updated to match the current EncryptionScheme protocol).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A number of test files existed on disk but were never members of the
SnapSafeTests target, so they silently never compiled or ran.

Removed (obsolete/superseded - tested types that no longer exist):
- SecureFileManagerTests, EditedPhotoTrackingTests (SecureFileManager removed)
- LocationManagerTests (replaced by LocationRepository)
- PINManagerTests (replaced by PinRepository/PinCrypto)
- SecurePhotoTests (SecurePhoto model removed)
- CameraModelTests (CameraModel renamed to CameraViewModel)
- CameraLifecycleTests (CameraViewModel.isSessionActive removed)
- FaceDetectorTests (MaskMode reduced to .pixelate; blurFaces removed)
- PhotoDetailViewModelTests (built on removed SecurePhoto + showFaceDetection)
- SnapSafeTests (empty Xcode template stub)

Folded into the target (valid tests of current code, with minor fixes):
- VerifyPinUseCaseTests - updated to current AuthorizePinUseCase /
  VerifyPinUseCase initializers; import FactoryKit; @mainactor.
- SECVFileFormatTests - UInt32 conversions; fileLength: label.
- SecureImageRepositoryTests - drop removed getPhotoByName tests; saveImage
  now takes CLLocation; add getVideosDirectory override for isolation.
- Added the previously-orphaned FakeEncryptionScheme / FakeThumbnailCache
  helpers to the target.

Full unit suite now compiles and runs: 92 passed, 0 failed (was 58).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously the poison pill destroyed ALL videos because videos could never be
marked as decoys: saveDecoySelections() skipped video items and there was no
video-decoy storage. Even preserving a video as-is would be broken, since
activatePoisonPill deletes the real key's DEK, making real-key content
undecryptable.

Mirror the photo decoy model for videos:
- VideoEncryptionService: add encryptVideoForDecoy(...) async (awaitable encrypt).
- SecureImageRepository: inject VideoEncryptionService; add isDecoyVideo,
  addDecoyVideoWithKey (decrypt with current key -> re-encrypt with poison-pill
  key into the decoy dir), removeDecoyVideo; count videos toward the shared
  decoy limit. deleteNonDecoyVideos now destroys non-decoy videos AND moves each
  decoy's poison-pill-key copy into the videos dir, replacing the real-key
  original (runs before deleteNonDecoyImages, which removes the decoy dir).
- AddDecoyVideoUseCase + DI wiring (and pass VideoEncryptionService into the
  SecureImageRepository factory).
- MixedMediaGalleryViewModel: saveDecoySelections add/remove decoy videos;
  decoy count, pre-selection, and strings now include videos.

Result: decoy videos are re-encrypted with the poison-pill key and remain
playable under the duress PIN; all non-decoy videos are destroyed.

Tests (PoisonPillVideoDeletionTests, + FakeVideoEncryptionService):
- non-decoy videos destroyed (decoy photo preserved)
- addDecoyVideoWithKey re-encrypts and marks the video a decoy
- end-to-end: mark decoy video -> poison pill -> decoy file replaced by the
  poison-pill-key copy, non-decoy video destroyed
Full unit suite: 93 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Marking a video as a decoy re-encrypts it with the poison-pill key, which can
take a while for large videos. Previously the decoy gallery dismissed
immediately and the work ran detached, giving the user no feedback.

- saveDecoySelections() is now async and drives isSavingDecoys + a
  completed/total counter (only items whose decoy state changes are processed).
- SecureGalleryView awaits the save and dismisses only when it completes,
  showing a dimmed spinner overlay ("Saving decoy media…" with an N-of-M count
  for multiple items). Save/Back are disabled while saving.

Note: the SECV re-encryption still runs on the main actor (the
VideoEncryptionService is @mainactor). The indeterminate ProgressView animates
on the render server so the spinner stays alive, but the rest of the UI is
blocked during the crypto. Moving the crypto off the main actor is a follow-up;
the recording path already consumes its progress via receive(on: .main), so it
should be feasible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video cells showed a generic film-icon placeholder. Now each video has a real
thumbnail. Because videos are encrypted SECV (unreadable by
AVAssetImageGenerator), the thumbnail is generated once at record time from the
plaintext .mov, before it is deleted.

- SecureImageRepository: durable, encrypted video-thumbnail storage in
  Application Support (videoThumbnails/, excluded from backup) — generate from
  the plaintext .mov via AVAssetImageGenerator.image(at:), store encrypted with
  the current key, read+decrypt with an in-memory cache, delete one / delete all.
- Security: thumbnails are derived from real frames, so deleteAllVideoThumbnails
  runs on poison-pill activation and security reset; per-video thumbnail (and any
  decoy copy) is removed when a video is deleted.
- ThumbnailCache: video-name-keyed get/put/evict (prefixed).
- CameraViewModel.encryptRecordedVideo generates+stores the thumbnail before the
  .mov is deleted.
- VideoCellView loads the decrypted thumbnail via .task (mirrors PhotoCell),
  with a play badge and decoy shield; falls back to the icon placeholder.

Scope: record-time only — videos recorded before this change and decoy videos
(after the pill) show the placeholder.

Tests (VideoThumbnailTests): store writes an encrypted file; read returns the
image; delete removes it; poison pill wipes the whole thumbnails dir. Full unit
suite: 97 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SecureGalleryViewModel was unreferenced (the live gallery uses
MixedMediaGalleryViewModel). Removed the 22KB class and file. The file also
declared the shared SelectionMode enum, which the live view model uses, so that
enum was moved into MixedMediaGalleryViewModel.swift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
~13 test files had silently never been added to the SnapSafeTests target, so
they never compiled and their tests never ran (the bundle reported success while
executing nothing). Add scripts/check_test_target_membership.rb, which fails if
any .swift under SnapSafeTests/ is not compiled by the target, and run it first
in the fastlane build/test lanes (so CI's `fastlane test` and local runs both
enforce it). Verified it fails on an orphan file and passes when clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rship guard

Add a "Building, Testing & Releasing" section covering the fastlane lanes, the
test-target membership guard (scripts/check_test_target_membership.rb) and how
it's enforced, the CI workflows, and the tag-driven release process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l deleted ALL videos

addDecoyVideoWithKey decrypted the original to a temp file and re-encrypted it
with the poison-pill key, but never created those output files. The video
encryption service opens its output with FileHandle(forWritingTo:), which
requires the file to already exist (the camera and the sharing path both
pre-create it). So every decrypt/encrypt threw, addDecoyVideoWithKey caught it
and returned false, and NO video was ever marked as a decoy.

Consequences, both reported by the user:
- Entering the poison PIN destroyed ALL videos, including ones the user tried to
  mark as decoys (deleteNonDecoyVideos found zero decoy files).
- The decoy shield badge never appeared on video cells (isDecoyVideo was always
  false).

Fix: pre-create the temp plaintext file and the decoy file before calling the
encryption service, matching the camera/sharing pattern.

Why tests missed it: FakeVideoEncryptionService wrote output via Data.write(to:),
which creates the file, masking the missing precondition. The fake now models
the real precondition (throws if the output file does not exist), so this class
of bug is caught. With the faithful fake the two decoy tests went RED, then GREEN
after the fix. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…haring + decoy marking)

decryptVideoFile read `upToCount: trailer.chunkSize` (the full 1 MB) for EVERY
chunk, but the encoder writes a partial final chunk (min(chunkSize, remaining)).
On the last chunk that over-read swallowed the auth tag (and index/trailer), so
the subsequent tag read returned nothing and decryption threw
SECVError.fileIOError -- for essentially any video (almost all have a partial
final chunk).

This silently broke every bulk-decrypt caller, decryptVideoForSharing:
- Marking a video as a decoy (addDecoyVideoWithKey decrypts then re-encrypts)
  always failed, so no video ever became a decoy. Hence the poison pill deleted
  ALL videos and the decoy shield badge never appeared.
- Video sharing (also decryptVideoForSharing) was broken the same way.
Playback was unaffected -- it streams via a custom AVAssetResourceLoader
(makeEncryptedVideoAsset), a different path.

Fix: read each chunk's actual size,
min(chunkSize, originalSize - chunkIndex*chunkSize); AES-GCM ciphertext length
equals the plaintext length, so this reads exactly the ciphertext and leaves the
tag intact.

This was the real root cause behind the decoy-video badge / "all videos deleted"
reports (the earlier saveDecoySelections and pre-create fixes were necessary but
not sufficient).

Tests (DecoyVideoIntegrationTests): real-service encrypt/decrypt round-trips for
single-chunk and multi-chunk-with-partial-last inputs (assert exact bytes), plus
an end-to-end "mark video as decoy -> isDecoyVideo true" using the real
VideoEncryptionService. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On poison-pill activation, deleteAllVideoThumbnails() destroys every video
thumbnail (they are derived from real frames and were encrypted with the
now-deleted real key). But decoy videos survive the pill, so they were left
with no thumbnail -> the gallery showed the placeholder icon.

Mirror the decoy-video mechanism for thumbnails: when a video is marked as a
decoy, re-encrypt its thumbnail with the poison-pill key into a separate
decoyVideoThumbnails/ directory. On the pill, after wiping the real-key
thumbnails, restore the decoy thumbnails into videoThumbnails/. They are
encrypted with the poison key (the active key after the pill), so
readVideoThumbnail decrypts them normally; clearAllThumbnails() flushes the
in-memory cache so no real thumbnail leaks.

Cleanup: removeDecoyVideo drops the decoy thumbnail copy; securityFailureReset
wipes the decoy thumbnail directory too.

Test (VideoThumbnailTests): marking a video as a decoy stores a poison-key
thumbnail; after the poison pill the decoy video's thumbnail is restored while a
non-decoy video's thumbnail is destroyed. Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Import picker only accepted images. Allow videos too: a picked video is
copied to a temp file (ImportedMovie transferable), encrypted to SECV with the
current key in the videos directory, and given a thumbnail -- mirroring the
camera record path. Imported videos use the camera's "video_yyyyMMdd_HHmmss"
naming (bumping the second on collision) so they stay unique and sort correctly.

Tests (VideoImportTests + DecoyVideoIntegrationTests): import creates an
encrypted .secv with the video_ prefix, the name is date-parseable/sortable,
repeated imports get distinct names, a non-decodable input still encrypts but
skips the thumbnail, and a real-service round-trip recovers the original bytes.
Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat black translucent backgrounds on the camera controls
(switch/flash/gallery/settings buttons, recording indicator, zoom capsule) with
a glassControlBackground modifier: Apple Liquid Glass (glassEffect) on iOS 26+,
with an .ultraThinMaterial fallback for the iOS 18.5 deployment floor. The
shutter keeps its dedicated design.

The glass is intentionally non-interactive: these backgrounds sit inside Buttons
and tap gestures, and interactive glass installs its own touch handling that
swallowed the button taps (regression: the flash toggle could not be changed).
The enclosing control owns the interaction; the modifier is purely visual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cwbooth5 added 2 commits June 14, 2026 22:52
This is to bring ios inline with android.
cwbooth5 and others added 10 commits June 15, 2026 02:03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… filter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This is three big changes in one. Wheee
This addresses some vulnerable dependencies.
Expands HOW-TO-RELEASE with the full merge, tag, and App Store Connect
flow, including the CI pipeline's behavior and retry gotchas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cwbooth5 cwbooth5 changed the title WIP: Encrypted Video/SECV Implementation Release 1.4.0: encrypted video, alphanumeric PINs Oct 9, 2026
The current macos-26 runner image keeps Xcode 26.0.1 installed but no
longer ships the iOS 26.0 platform, so every build failed with "iOS 26.0
is not installed". Xcode 26.5 matches the local toolchain used for testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cwbooth5 cwbooth5 mentioned this pull request Oct 9, 2026
3 of 4 tasks
Pin Xcode 26.5 (Xcode_16.2 no longer exists on macos-latest), skip
macro fingerprint validation for Mockable, build against a generic
simulator destination instead of a hardcoded iOS 18.4 SDK/device, and
pin action references to commit SHAs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cwbooth5 cwbooth5 closed this Oct 9, 2026
@cwbooth5 cwbooth5 reopened this Oct 9, 2026
Resolve SPM packages before CodeQL init so the fetches aren't traced
(13.5 min under the tracer vs 12 s without), and build arm64 only
instead of arm64 + x86_64 for the generic simulator destination.

Ignore docs/superpowers/ so plans and specs stay out of the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cwbooth5 and others added 7 commits October 11, 2026 17:13
json 2.20.0's parser (json_decode_object, duplicate-key handling) is on
the ordinary JSON.parse path that fastlane uses for App Store Connect
responses, so the advisory is reachable via the beta/deploy lanes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bump github/codeql-action to v4.38.2 (v3 is deprecated in December
2026) and actions/checkout to v6, matching build-and-test.yml. Both run
on Node 24, clearing the Node 20 deprecation warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A global ARCHS=arm64 also applied to the macOS macro host targets and
broke the MockableMacro build on CI ("None of the architectures in
ARCHS (arm64) are valid"). Set ARCHS via an xcconfig conditional on the
iphonesimulator SDK so only the app's simulator slice drops x86_64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolving packages before CodeQL init didn't shorten the traced build:
the ~12.5 min pause before "Resolved source packages" happened with no
fetches at all, so it isn't git work. Drop the extra ~45 s step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the Android app's PIN creation options:
- Raise the maximum PIN length from 10 to 16 characters.
- Add a help button next to the alphanumeric toggle on the PIN setup
  screen explaining how much stronger alphanumeric PINs are.

Also remove an unused, outdated copy of PINSetupViewModel that was not
in the Xcode project, and clear SwiftLint warnings in the touched files
(rename MIN/MAX_PIN_LENGTH to minPinLength/maxPinLength, whitespace,
trailing-closure and line-length fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UIPageViewController keeps neighboring pages attached to the window, so
SwiftUI's onDisappear never fired for a swiped-away video page and its
audio kept playing off-screen. Drive playback setup/teardown from the
hosting controller's viewWillAppear/viewDidDisappear instead, and show
the loading state when a cleaned-up page reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cwbooth5 cwbooth5 self-assigned this Oct 12, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant