diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c5ebffb..12e5d2b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set Xcode version - run: sudo xcode-select -s /Applications/Xcode_26.0.1.app + run: sudo xcode-select -s /Applications/Xcode_26.5.app - name: Use the exact SPM lockfile run: | @@ -32,7 +32,7 @@ jobs: -scheme SnapSafe - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1 with: bundler-cache: true ruby-version: '3.4' @@ -50,7 +50,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results path: | @@ -64,7 +64,7 @@ jobs: - name: Publish test results if: always() - uses: EnricoMi/publish-unit-test-result-action/macos@v2 + uses: EnricoMi/publish-unit-test-result-action/macos@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2 with: files: | fastlane/test_output/*.junit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3a1161a..e7e045f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,7 +49,7 @@ jobs: build-mode: manual steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` @@ -57,13 +57,18 @@ jobs: # - name: Setup runtime (example) # uses: actions/setup-example@v1 - - name: Set Xcode version 16.2 + - name: Set Xcode version if: ${{ matrix.language == 'swift' }} - run: sudo xcode-select -s /Applications/Xcode_16.2.app + run: sudo xcode-select -s /Applications/Xcode_26.5.app + + - name: Disable macro validation + if: ${{ matrix.language == 'swift' }} + run: | + defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -83,9 +88,16 @@ jobs: - if: matrix.build-mode == 'manual' shell: bash run: | - xcodebuild -project SnapSafe.xcodeproj/ -scheme SnapSafe -configuration Debug -sdk iphonesimulator18.4 -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4' -derivedDataPath ./build CODE_SIGNING_ALLOWED=NO clean build + # Build the simulator slice for arm64 only. Scoped to the simulator SDK + # so macro host tools (Mockable/swift-syntax) keep their default archs; + # a global ARCHS=arm64 breaks the MockableMacro build. + echo 'ARCHS[sdk=iphonesimulator*] = arm64' > "$RUNNER_TEMP/codeql-arm64.xcconfig" + xcodebuild -project SnapSafe.xcodeproj -scheme SnapSafe -configuration Debug \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath ./build \ + -xcconfig "$RUNNER_TEMP/codeql-arm64.xcconfig" \ + -skipMacroValidation CODE_SIGNING_ALLOWED=NO clean build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/notify-release.yml b/.github/workflows/notify-release.yml index 9ff2403..77a2a2b 100644 --- a/.github/workflows/notify-release.yml +++ b/.github/workflows/notify-release.yml @@ -11,6 +11,6 @@ jobs: - name: Discord notification env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # master with: args: '**New SnapSafe iOS release:** ${{ github.event.release.name }} \n\n **Download Here:** \n\n ${{ github.event.release.html_url }}' diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6ff305c..1707fb2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,16 +15,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Set Xcode version - run: sudo xcode-select -s /Applications/Xcode_26.0.1.app + run: sudo xcode-select -s /Applications/Xcode_26.5.app - name: Show Xcode version run: xcodebuild -version - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1 with: ruby-version: '3.4' bundler-cache: true @@ -71,7 +71,7 @@ jobs: echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: name: Release ${{ env.VERSION_NAME }} draft: false @@ -88,16 +88,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Set Xcode version - run: sudo xcode-select -s /Applications/Xcode_26.0.1.app + run: sudo xcode-select -s /Applications/Xcode_26.5.app - name: Show Xcode version run: xcodebuild -version - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1 with: ruby-version: '3.4' bundler-cache: true diff --git a/.gitignore b/.gitignore index 6273d00..562c067 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,13 @@ Configs/LocalOverrides.xcconfig vendor/ # fastlane snapshot -screenshots/ \ No newline at end of file +screenshots/ + +SecureCameraAndroid/ + +# Local TODO scratch +TODO.md + +.claude/worktrees +.superpowers/ +docs/superpowers/ diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..426a90f --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,6 @@ +project: SnapSafe.xcworkspace +schemes: + - SnapSafe +clean_build: true +build_arguments: + - -skipMacroValidation diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f569ba0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# Agent guide for Swift and SwiftUI + +This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. + + +## Role + +You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. + + +## Core instructions + +- Target iOS 26.0 or later. (Yes, it definitely exists.) +- Swift 6.2 or later, using modern Swift concurrency. Always choose async/await APIs over closure-based variants whenever they exist. +- SwiftUI backed up by `@Observable` classes for shared data. +- Do not introduce third-party frameworks without asking first. +- Avoid UIKit unless requested. + + +## Swift instructions + +- `@Observable` classes must be marked `@MainActor` unless the project has Main Actor default actor isolation. Flag any `@Observable` class missing this annotation. +- All shared data should use `@Observable` classes with `@State` (for ownership) and `@Bindable` / `@Environment` (for passing). +- Strongly prefer not to use `ObservableObject`, `@Published`, `@StateObject`, `@ObservedObject`, or `@EnvironmentObject` unless they are unavoidable, or if they exist in legacy/integration contexts when changing architecture would be complicated. +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`. +- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app’s documents directory, and `appending(path:)` to append strings to a URL. +- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead. +- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. +- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. +- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`. +- Avoid force unwraps and force `try` unless it is unrecoverable. +- Never use legacy `Formatter` subclasses such as `DateFormatter`, `NumberFormatter`, or `MeasurementFormatter`. Always use the modern `FormatStyle` API instead. For example, to format a date, use `myDate.formatted(date: .abbreviated, time: .shortened)`. To parse a date from a string, use `Date(inputString, strategy: .iso8601)`. For numbers, use `myNumber.formatted(.number)` or custom format styles. + +## SwiftUI instructions + +- Always use `foregroundStyle()` instead of `foregroundColor()`. +- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. +- Always use the `Tab` API instead of `tabItem()`. +- Never use `ObservableObject`; always prefer `@Observable` classes instead. +- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. +- Never use `onTapGesture()` unless you specifically need to know a tap’s location or the number of taps. All other usages should use `Button`. +- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. +- Never use `UIScreen.main.bounds` to read the size of the available space. +- Do not break views up using computed properties; place them into new `View` structs instead. +- Do not force specific font sizes; prefer using Dynamic Type instead. +- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`. +- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`. +- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`. +- Don’t apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`. +- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`. +- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`. +- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. +- Use the newest ScrollView APIs for item scrolling and positioning (e.g. `ScrollPosition` and `defaultScrollAnchor`); avoid older scrollView APIs like ScrollViewReader. +- Place view logic into view models or similar, so it can be tested. +- Avoid `AnyView` unless it is absolutely required. +- Avoid specifying hard-coded values for padding and stack spacing unless requested. +- Avoid using UIKit colors in SwiftUI code. + + +## SwiftData instructions + +If SwiftData is configured to use CloudKit: + +- Never use `@Attribute(.unique)`. +- Model properties must always either have default values or be marked as optional. +- All relationships must be marked optional. + + +## Project structure + +- Use a consistent project structure, with folder layout determined by app features. +- Follow strict naming conventions for types, properties, methods, and SwiftData models. +- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Write unit tests for core application logic. +- Only write UI tests if unit tests are not possible. +- Add code comments and documentation comments as needed. +- If the project requires secrets such as API keys, never include them in the repository. +- If the project uses Localizable.xcstrings, prefer to add user-facing strings using symbol keys (e.g. helloWorld) in the string catalog with `extractionState` set to "manual", accessing them via generated symbols such as `Text(.helloWorld)`. Offer to translate new keys into all languages supported by the project. + + +## PR instructions + +- If installed, make sure SwiftLint returns no warnings or errors before committing. + + +## Xcode MCP + +If the Xcode MCP is configured, prefer its tools over generic alternatives when working on this project: + +- `DocumentationSearch` — verify API availability and correct usage before writing code +- `BuildProject` — build the project after making changes to confirm compilation succeeds +- `GetBuildLog` — inspect build errors and warnings +- `RenderPreview` — visually verify SwiftUI views using Xcode Previews +- `XcodeListNavigatorIssues` — check for issues visible in the Xcode Issue Navigator +- `ExecuteSnippet` — test a code snippet in the context of a source file +- `XcodeRead`, `XcodeWrite`, `XcodeUpdate` — prefer these over generic file tools when working with Xcode project files + diff --git a/Gemfile.lock b/Gemfile.lock index 0fda62b..8c19e00 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,13 +6,13 @@ GEM nkf rexml abbrev (0.1.2) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1172.0) - aws-sdk-core (3.233.0) + aws-partitions (1.1268.0) + aws-sdk-core (3.254.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -20,31 +20,34 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.113.0) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.199.1) - aws-sdk-core (~> 3, >= 3.231.0) + aws-sdk-s3 (1.227.0) + aws-sdk-core (~> 3, >= 3.254.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) - bigdecimal (3.3.1) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.4) + excon (1.5.0) + logger + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -63,46 +66,55 @@ GEM faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.1) + faraday-multipart (1.2.0) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.228.0) - CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.8, < 3.0.0) + fastlane (2.237.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) + excon (>= 0.71.0, < 2.0.0) faraday (~> 1.0) faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -115,8 +127,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -137,8 +148,9 @@ GEM google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) google-cloud-errors (1.5.0) google-cloud-storage (1.47.0) addressable (~> 2.8) @@ -148,11 +160,14 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) + google-logging-utils (0.2.0) + googleauth (1.17.0) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) os (>= 0.9, < 2.0) + pstore (~> 0.1) signet (>= 0.16, < 2.a) highline (2.0.3) http-cookie (1.0.8) @@ -160,8 +175,8 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.15.1) - jwt (2.10.2) + json (2.21.2) + jwt (3.2.0) base64 logger (1.7.0) mini_magick (4.13.2) @@ -176,7 +191,8 @@ GEM os (1.1.4) ostruct (0.6.1) plist (3.7.2) - public_suffix (6.0.2) + pstore (0.2.1) + public_suffix (7.0.5) rake (13.3.0) representable (3.2.0) declarative (< 0.1.0) @@ -196,7 +212,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 16eba2b..1c792fc 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -4,28 +4,20 @@ "" : { }, - "%@" : { + "(from filename)" : { }, - "%lld" : { + "%@" : { }, - "%lld × %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld × %2$lld" - } - } - } - }, - "%lld faces detected, %lld selected" : { + "%@ / %@" : { + "comment" : "Displays the current playback time and the total duration of the video, formatted as a string.", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld faces detected, %2$lld selected" + "value" : "%1$@ / %2$@" } } } @@ -40,16 +32,6 @@ } } }, - "%lld/%lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld/%2$lld" - } - } - } - }, "%lld%%" : { }, @@ -71,14 +53,23 @@ "About" : { }, - "About SnapSafe" : { - + "About Alphanumeric PINs" : { + "comment" : "A button that shows an alert with information about alphanumeric PINs.", + "isCommentAutoGenerated" : true }, - "Add Box" : { + "About SnapSafe" : { }, "All Metadata" : { + }, + "Alphanumeric PINs" : { + "comment" : "The title of an alert that explains alphanumeric PINs.", + "isCommentAutoGenerated" : true + }, + "Alphanumeric PINs allow letters (a-z, A-Z) in addition to digits (0-9).\n\nThis dramatically increases security:\n\n• A 4-digit numeric PIN has 10,000 possible combinations\n• A 4-character alphanumeric PIN has over 14 million combinations\n\nUsing letters and numbers together makes your PIN much harder to guess or brute-force." : { + "comment" : "Help text for the alphanumeric PIN option.", + "isCommentAutoGenerated" : true }, "Aperture" : { @@ -88,19 +79,13 @@ }, "Are you sure you want to %@ the selected faces? This action cannot be undone." : { - }, - "Are you sure you want to delete %lld photo%@? This action cannot be undone." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Are you sure you want to delete %1$lld photo%2$@? This action cannot be undone." - } - } - } }, "Are you sure you want to delete this photo? This action cannot be undone." : { + }, + "Are you sure you want to delete this video? This action cannot be undone." : { + "comment" : "An alert message displayed when the user attempts to delete a video.", + "isCommentAutoGenerated" : true }, "Are you sure you want to obscure the selected areas? This action cannot be undone." : { @@ -111,7 +96,7 @@ "Are you sure you want to reset all security settings to default? This action cannot be undone." : { }, - "Are you sure you want to save these %lld photos as decoys? These will be shown when the emergency PIN is entered." : { + "Auto-Play Videos" : { }, "Back" : { @@ -119,6 +104,10 @@ }, "Basic Information" : { + }, + "Bitrate" : { + "comment" : "A label for the bitrate of a video.", + "isCommentAutoGenerated" : true }, "Camera" : { @@ -128,12 +117,23 @@ }, "Camera access is required to take photos. Please enable camera access in Settings." : { + }, + "Camera access required" : { + "comment" : "A hint that appears when the app does not have permission to access the camera.", + "isCommentAutoGenerated" : true }, "Camera Information" : { }, "Cancel" : { + }, + "Capture mode" : { + "comment" : "A label describing the capture mode setting in the camera interface.", + "isCommentAutoGenerated" : true + }, + "Capture Mode" : { + }, "Choose a different PIN than the one used to unlock this device!" : { @@ -141,17 +141,21 @@ "Choose how the app appears. System follows your device's appearance setting." : { }, - "Come engage with our community, discover more Free and Open Source Software!" : { + "Codec" : { }, - "Community" : { + "Come engage with our community, discover more Free and Open Source Software!" : { }, - "Confirm PIN" : { + "Community" : { }, "Continue" : { + }, + "Could not play video" : { + "comment" : "An error message displayed when a video cannot be played.", + "isCommentAutoGenerated" : true }, "Create a PIN that will trigger emergency deletion" : { @@ -161,6 +165,10 @@ }, "Date Taken" : { + }, + "Decoy Limit Reached" : { + "comment" : "An alert title indicating that the user has reached the maximum number of decoy items.", + "isCommentAutoGenerated" : true }, "Decoy Photos" : { @@ -174,14 +182,32 @@ "Delete Photo" : { }, - "Delete Photo%@" : { - - }, - "Detect Faces" : { - + "Delete Video" : { + "comment" : "A title for an alert that asks the user to confirm deleting a video.", + "isCommentAutoGenerated" : true }, "Done" : { + }, + "Double-tap to %@" : { + "comment" : "A hint that appears when a user interacts with a photo cell. The hint varies depending on whether the cell is in selection mode or not.", + "isCommentAutoGenerated" : true + }, + "Double-tap to cycle flash mode" : { + "comment" : "A hint that appears when hovering over the flash button, explaining how to cycle the flash mode.", + "isCommentAutoGenerated" : true + }, + "Double-tap to open" : { + "comment" : "A hint that appears when hovering over a gallery photo, indicating that it can be tapped to view it.", + "isCommentAutoGenerated" : true + }, + "Double-tap to reset zoom. Single-tap to open slider." : { + "comment" : "An accessibility hint for the zoom indicator, explaining how to interact with it.", + "isCommentAutoGenerated" : true + }, + "Duration" : { + "comment" : "A label displayed alongside the duration of a video.", + "isCommentAutoGenerated" : true }, "Emergency Data Deletion" : { @@ -189,11 +215,9 @@ "Emergency security feature that permanently deletes all data when triggered" : { }, - "Enter new PIN" : { - - }, - "Enter PIN" : { - + "Encrypting & saving…" : { + "comment" : "A label displayed while a video is being encrypted and saved.", + "isCommentAutoGenerated" : true }, "Enter your PIN to continue" : { "localizations" : { @@ -210,6 +234,10 @@ }, "Filename" : { + }, + "Flash: %@" : { + "comment" : "The accessibility label for the flash button.", + "isCommentAutoGenerated" : true }, "Focal Length" : { @@ -219,9 +247,25 @@ }, "Found a bug? Report it on GitHub:" : { + }, + "Frame Rate" : { + "comment" : "The name of the video frame rate property.", + "isCommentAutoGenerated" : true + }, + "Gallery" : { + "comment" : "A button to view the user's photo gallery.", + "isCommentAutoGenerated" : true + }, + "Gallery is empty. Use the camera to take your first photo." : { + "comment" : "An accessibility label for the empty state of the gallery view.", + "isCommentAutoGenerated" : true }, "GitHub" : { + }, + "Hide PIN" : { + "comment" : "A button label that hides the PIN.", + "isCommentAutoGenerated" : true }, "Image Information" : { @@ -231,54 +275,47 @@ }, "Importing photos..." : { - }, - "Info" : { - - }, - "Invalid PIN. Please try again." : { - }, "ISO" : { }, "Join our Discord" : { + }, + "Letters and numbers allowed" : { + "comment" : "A description of which characters are allowed in the PIN field.", + "isCommentAutoGenerated" : true }, "Loading image information..." : { }, "Loading image..." : { + }, + "Loading video information..." : { + "comment" : "The text that appears while a video's metadata is being loaded.", + "isCommentAutoGenerated" : true }, "Loading..." : { }, "Location" : { - }, - "Manage Permission in Settings" : { - }, "Mark Decoys" : { - }, - "Mask Faces" : { - }, "More" : { }, - "No camera information available" : { + "Mute" : { }, - "No faces detected" : { + "No camera information available" : { }, "No photos yet" : { - }, - "Obfuscate" : { - }, "Obscure Areas" : { @@ -308,6 +345,10 @@ }, "Original Date" : { + }, + "Pause" : { + "comment" : "The label for the \"Pause\" button in the video transport bar.", + "isCommentAutoGenerated" : true }, "Perform Security Reset" : { @@ -321,8 +362,17 @@ "Photo Obfuscation" : { }, - "PIN" : { - + "Photo: %@" : { + "comment" : "An element in the UI that represents a photo. The label inside is the name of the photo.", + "isCommentAutoGenerated" : true + }, + "Play" : { + "comment" : "The text for the play button in the video transport bar.", + "isCommentAutoGenerated" : true + }, + "Playback Error" : { + "comment" : "A title for an error view that appears when video playback fails.", + "isCommentAutoGenerated" : true }, "Please create a PIN to secure your photos" : { @@ -335,6 +385,9 @@ }, "Poison pill is configured and ready" : { + }, + "Precision" : { + }, "Privacy" : { @@ -347,6 +400,10 @@ }, "Raw Metadata" : { + }, + "Recording: %@" : { + "comment" : "A view that appears when a video is being recorded. It shows a red dot and the duration of the recording.", + "isCommentAutoGenerated" : true }, "Remove" : { @@ -354,13 +411,13 @@ "Remove Poison Pill" : { }, - "Report Bug" : { + "Replay" : { }, - "Report Bugs" : { + "Report Bug" : { }, - "Request Location Permission" : { + "Report Bugs" : { }, "Reset" : { @@ -374,6 +431,10 @@ }, "Resolution" : { + }, + "Retry" : { + "comment" : "A button label that says \"Retry\".", + "isCommentAutoGenerated" : true }, "Sanitize File Name" : { "localizations" : { @@ -393,6 +454,16 @@ }, "Save Decoy Selection" : { + }, + "Saving decoy media" : { + + }, + "Saving decoy media…" : { + + }, + "Saving photo" : { + "comment" : "A hint that appears when a photo is being saved.", + "isCommentAutoGenerated" : true }, "Screen Recording Detected" : { @@ -409,7 +480,7 @@ "Select for Decoys" : { }, - "Select Photos" : { + "Select Items" : { }, "Select to Delete" : { @@ -450,6 +521,10 @@ }, "Sharing Options" : { + }, + "Show PIN" : { + "comment" : "A button to show the user's PIN.", + "isCommentAutoGenerated" : true }, "Shutter Speed" : { @@ -472,11 +547,25 @@ "SnapSafe.org" : { }, - "Tap anywhere on the image to add a custom box" : { - + "Start recording" : { + "comment" : "A button label that indicates that recording has started.", + "isCommentAutoGenerated" : true }, - "Tap faces to select them for masking. Pinch to resize boxes." : { - + "Stop recording" : { + "comment" : "The text for a button that stops recording a video.", + "isCommentAutoGenerated" : true + }, + "Switch to front camera" : { + "comment" : "A label describing the action of switching to the front camera.", + "isCommentAutoGenerated" : true + }, + "Switch to rear camera" : { + "comment" : "An accessibility label for the button that switches the camera to the rear.", + "isCommentAutoGenerated" : true + }, + "Take photo" : { + "comment" : "A button that triggers taking a photo.", + "isCommentAutoGenerated" : true }, "The camera app that minds its own business." : { @@ -487,14 +576,33 @@ "Too Many Decoys" : { }, - "Unlock" : { + "Unmute" : { }, - "Verifying..." : { + "Use Alphanumeric PIN" : { }, "Version %@" : { + }, + "Video" : { + + }, + "Video Information" : { + "comment" : "A title for a view that shows progress while loading data.", + "isCommentAutoGenerated" : true + }, + "Video: %@" : { + "comment" : "A video cell in the gallery. The argument is the name of the video.", + "isCommentAutoGenerated" : true + }, + "Warning: 10 failed attempts will result in a full data wipe. All photos will be lost." : { + "comment" : "A warning message explaining that 10 failed attempts will result in a full data wipe, and that all photos will be lost.", + "isCommentAutoGenerated" : true + }, + "Warning: one attempt remaining before data wipe" : { + "comment" : "A text that appears when a user has one failed attempt left before their data will be permanently deleted.", + "isCommentAutoGenerated" : true }, "When enabled, location data will be embedded in newly captured photos. Location requires permission and GPS availability." : { @@ -502,11 +610,16 @@ "When enabled, personal information will be removed from photos before sharing" : { }, - "When entered, this PIN it will immediately and permanently delete all photos and encryption keys." : { - + "When entered, this PIN will immediately and permanently delete all photos and encryption keys." : { + "comment" : "A warning displayed below the \"Set Poison Pill PIN\" section, explaining the security risk of using it.", + "isCommentAutoGenerated" : true }, - "You can select a maximum of %lld decoy photos. Please deselect some photos before saving." : { + "When on, videos start playing automatically as you swipe to them. When off, they wait paused on the first frame until you tap play." : { + }, + "You can have a maximum of 10 decoy items. Remove an existing decoy before adding a new one." : { + "comment" : "An alert message displayed when a user attempts to add a new decoy item when they have already reached the maximum limit.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/README.md b/README.md index 496035e..9514d02 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,78 @@ Settings → Face ID & Passcode (or Touch ID & Passcode) → Allow Access When L Verify the setting is **disabled** (the default). +# Building, Testing & Releasing + +The build, test, and release pipeline is driven by [fastlane](https://fastlane.tools). +Everything runs through `bundle exec fastlane `, so install the Ruby +dependencies once: + +```bash +bundle install +``` + +## Fastlane lanes + +| Lane | What it does | +| ---- | ------------ | +| `fastlane build` | Compiles the app *for testing* (`build_for_testing`). | +| `fastlane test` | Runs the `SnapSafeTests` unit suite on an iPhone simulator. | +| `fastlane run_multi_version_tests` | Runs the unit suite across multiple iOS versions (18.5 and 26.0). | +| `fastlane verify_test_membership` | Runs the test-target membership guard on its own (see below). | +| `fastlane build_release` | Builds a signed App Store IPA into `./build` (`gym`). | +| `fastlane beta` | Builds and uploads to TestFlight. | +| `fastlane deploy` | Builds and uploads to App Store Connect. | + +Common settings live in `fastlane/Scanfile` (test config), `fastlane/Snapfile` +(screenshots), and `fastlane/Appfile` (app identifier). + +## Test-target membership guard + +Test files in Xcode must be explicitly added to the `SnapSafeTests` target. A +`.swift` file that exists on disk but isn't a member is silently never compiled — +its tests never run, while the test bundle still reports success. To prevent this, +`scripts/check_test_target_membership.rb` fails if any `.swift` file under +`SnapSafeTests/` is not compiled by the target. + +The `build` and `test` lanes run this guard **first**, so it is enforced both +locally and in CI (CI runs `fastlane test`). Run it directly with: + +```bash +bundle exec fastlane verify_test_membership +# or: +bundle exec ruby scripts/check_test_target_membership.rb +``` + +On failure it lists the offending files; add each to the `SnapSafeTests` target +(Xcode → File Inspector → Target Membership) and re-run. + +## Continuous integration + +| Workflow | Trigger | Does | +| -------- | ------- | ---- | +| `.github/workflows/build-and-test.yml` | push / PR to `main` | `fastlane test` (membership guard + unit tests), publishes results. | +| `.github/workflows/codeql.yml` | push / PR / schedule | CodeQL security analysis. | +| `.github/workflows/publish-release.yml` | push of a `v*` tag | `fastlane build_release` → GitHub Release, then `fastlane deploy` → App Store Connect. | +| `.github/workflows/notify-release.yml` | GitHub release published | Sends a release notification. | + +## Cutting a release + +1. Make sure `main` is green (the build-and-test workflow passes). +2. Tag the release commit and push the tag: + + ```bash + git tag v1.3.0 + git push origin v1.3.0 + ``` + +3. The `Publish iOS Release` workflow builds the IPA, creates the GitHub Release, + and uploads the build to App Store Connect. + +Release signing/upload requires the repository secrets used by the workflow: +the distribution certificate/profile import, and the App Store Connect API key +(`APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, +`APP_STORE_CONNECT_API_KEY_CONTENT`). + # Contributing Take a look at our [development](docs/DEVELOPMENT.md) docs. diff --git a/Signing.xcconfig b/Signing.xcconfig new file mode 100644 index 0000000..76a0dee --- /dev/null +++ b/Signing.xcconfig @@ -0,0 +1,3 @@ +CODE_SIGN_STYLE = Automatic +DEVELOPMENT_TEAM = ABCDE12345 // the org's ID (not your personal one, we will use a different ID here later maybe) +#include? "Configs/LocalOverrides.xcconfig" // relative to project root, your local config diff --git a/Snap-Safe-Info.plist b/Snap-Safe-Info.plist index 6631ffa..72d304f 100644 --- a/Snap-Safe-Info.plist +++ b/Snap-Safe-Info.plist @@ -2,5 +2,13 @@ + + ITSAppUsesNonExemptEncryption + diff --git a/SnapSafe.xcodeproj/project.pbxproj b/SnapSafe.xcodeproj/project.pbxproj index 8e93bb3..8e4e0ef 100644 --- a/SnapSafe.xcodeproj/project.pbxproj +++ b/SnapSafe.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 06380B44AA837F59C33FFAF0 /* AddDecoyVideoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60E8772D487C47F35C819B2 /* AddDecoyVideoUseCase.swift */; }; + 0A39B5BB99D38FD752C33D40 /* InlineVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345B31B24DBF8A6CAC9E2617 /* InlineVideoPlayerView.swift */; }; + 113AED184D13916EBB009C93 /* MediaDetailToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C2F7E4B3B5397EF48DF183 /* MediaDetailToolbar.swift */; }; + 182F66A484EDD7D5670EBE15 /* VideoThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9286AA1AF0A4DF1140718E06 /* VideoThumbnailTests.swift */; }; + 24194F171D3CBDF42B72D556 /* HardwareEncryptionSchemeFileProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B07498650554419769A4053 /* HardwareEncryptionSchemeFileProtectionTests.swift */; }; + 24194F181D3CBDF42B72D557 /* HardwareEncryptionSchemeSecurityResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B07498750554419769A4054 /* HardwareEncryptionSchemeSecurityResetTests.swift */; }; + 33145A757800B951872791FC /* HardwareEncryptionSchemePinBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0EEE6230116B9BC41B148B /* HardwareEncryptionSchemePinBindingTests.swift */; }; + 38579EABF27707E732CDC069 /* PinDEKWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11F4D9DABB01000AED1127 /* PinDEKWrapper.swift */; }; 660130A02E676F5B00D07E9C /* FactoryKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6601309F2E676F5B00D07E9C /* FactoryKit */; }; 660130A22E676F5B00D07E9C /* FactoryTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 660130A12E676F5B00D07E9C /* FactoryTesting */; }; 660130A92E67753600D07E9C /* AppDependencyInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660130A82E67753600D07E9C /* AppDependencyInjection.swift */; }; @@ -66,7 +74,6 @@ 667FF8292E6CAE1000FB3E02 /* CombineExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF8282E6CAE0C00FB3E02 /* CombineExt.swift */; }; 667FF82B2E6CB78000FB3E02 /* getRotationAngle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF82A2E6CB1C400FB3E02 /* getRotationAngle.swift */; }; 667FF82D2E6CC06900FB3E02 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF82C2E6CC06900FB3E02 /* SettingsViewModel.swift */; }; - 667FF82F2E6CC33B00FB3E02 /* SecureGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF82E2E6CC33B00FB3E02 /* SecureGalleryViewModel.swift */; }; 667FF8312E6CD94500FB3E02 /* PINVerificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF8302E6CD94500FB3E02 /* PINVerificationViewModel.swift */; }; 667FF8332E6D0FF800FB3E02 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF8322E6D0FF800FB3E02 /* ContentView.swift */; }; 667FF8352E6D101300FB3E02 /* AppNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667FF8342E6D101300FB3E02 /* AppNavigation.swift */; }; @@ -85,6 +92,13 @@ 66A404DA2E694E2C0054FFE7 /* Mockable in Frameworks */ = {isa = PBXBuildFile; productRef = 66A404D92E694E2C0054FFE7 /* Mockable */; }; 66A404DC2E69537E0054FFE7 /* Mockable in Frameworks */ = {isa = PBXBuildFile; productRef = 66A404DB2E69537E0054FFE7 /* Mockable */; }; 66DE21CF2E69750C00AC94DA /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66DE21CE2E69750600AC94DA /* Json.swift */; }; + 66FFC0DE2F3A000100C0B617 /* VideoCaptureService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FFC0DE2F3A000000C0B617 /* VideoCaptureService.swift */; }; + 68109942731A0033DBA31CA8 /* PoisonPillVideoDeletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC41CA572369E73F5CB7451 /* PoisonPillVideoDeletionTests.swift */; }; + 6D125407D63ACE7CF6CB74FE /* OrientationRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10BAC24976F36840D24E6B6 /* OrientationRotationTests.swift */; }; + 71A1063EE417231D3E6A771B /* SECVFileFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCDFD42CA72A9C8FA98EDCD /* SECVFileFormatTests.swift */; }; + 78BAE12E96629EA55F066179 /* SecureImageRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA2FF82666960557F17548E /* SecureImageRepositoryTests.swift */; }; + 7CBC61415276C81597CDBF80 /* VerifyPinUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73AE08F5261FA581EF832FE5 /* VerifyPinUseCaseTests.swift */; }; + 86FA0BDF73A263C07D744E4D /* FileBasedSettingsDataSourceProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBF89B43CD2D2FE8EBA109 /* FileBasedSettingsDataSourceProtectionTests.swift */; }; A91DBC542DE58191001F42ED /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91DBC252DE58191001F42ED /* AppearanceMode.swift */; }; A91DBC552DE58191001F42ED /* DetectedFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91DBC262DE58191001F42ED /* DetectedFace.swift */; }; A91DBC562DE58191001F42ED /* MaskMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91DBC272DE58191001F42ED /* MaskMode.swift */; }; @@ -108,6 +122,33 @@ A91DBC792DE58191001F42ED /* SnapSafeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91DBC522DE58191001F42ED /* SnapSafeApp.swift */; }; A91DBC7A2DE58191001F42ED /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A91DBC2C2DE58191001F42ED /* Preview Assets.xcassets */; }; A91DBC7B2DE58191001F42ED /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A91DBC3F2DE58191001F42ED /* Assets.xcassets */; }; + A95B2E252F31D19700EE7291 /* SECVFileFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E242F31D19700EE7291 /* SECVFileFormat.swift */; }; + A95B2E262F31D19700EE7291 /* SECVFileFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E242F31D19700EE7291 /* SECVFileFormat.swift */; }; + A95B2E272F31D19700EE7291 /* SECVFileFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E242F31D19700EE7291 /* SECVFileFormat.swift */; }; + A95B2E2A2F42F0FC00EE7291 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E282F42F0FC00EE7291 /* MediaItem.swift */; }; + A95B2E2B2F42F0FC00EE7291 /* VideoEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E292F42F0FC00EE7291 /* VideoEncryptionService.swift */; }; + A95B2E2D2F42F16C00EE7291 /* MixedMediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E2C2F42F16C00EE7291 /* MixedMediaGalleryViewModel.swift */; }; + A95B2E2F2F42F18F00EE7291 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E2E2F42F18F00EE7291 /* VideoPlayerView.swift */; }; + A95B2E312F42F1A700EE7291 /* EncryptedVideoDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B2E302F42F1A700EE7291 /* EncryptedVideoDataSource.swift */; }; + A98EBC112FDE079C00FA9CCB /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC102FDE079B00FA9CCB /* ImageProcessing.swift */; }; + A98EBC132FDE07AB00FA9CCB /* ImageProcessingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC122FDE07AB00FA9CCB /* ImageProcessingTests.swift */; }; + A98EBC1F2FDE170C00FA9CCB /* ImageMetadataTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC1E2FDE170C00FA9CCB /* ImageMetadataTypes.swift */; }; + A98EBC212FDE1ACD00FA9CCB /* PhotoStorageDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC202FDE1ACD00FA9CCB /* PhotoStorageDataSource.swift */; }; + A98EBC232FDE1B1300FA9CCB /* PhotoStorageDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC222FDE1B1300FA9CCB /* PhotoStorageDataSourceTests.swift */; }; + A98EBC252FDE4C3B00FA9CCB /* PINEntryField.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC242FDE4C3B00FA9CCB /* PINEntryField.swift */; }; + A98EBC632FDF673700FA9CCB /* AVMetadataItemFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC622FDF673700FA9CCB /* AVMetadataItemFactoryTests.swift */; }; + A98EBC652FDF681000FA9CCB /* AVMetadataItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC642FDF681000FA9CCB /* AVMetadataItemFactory.swift */; }; + A98EBC672FDF6D1500FA9CCB /* VideoMetaDataFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC662FDF6D1500FA9CCB /* VideoMetaDataFormattingTests.swift */; }; + A98EBC692FDF6DD000FA9CCB /* VideoMetaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC682FDF6DD000FA9CCB /* VideoMetaData.swift */; }; + A98EBC6B2FDF702B00FA9CCB /* VideoMetaDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC6A2FDF702B00FA9CCB /* VideoMetaDataTests.swift */; }; + A98EBC6D2FDF743100FA9CCB /* VideoInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC6C2FDF743100FA9CCB /* VideoInfoViewModel.swift */; }; + A98EBC6F2FDF74C500FA9CCB /* VideoInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBC6E2FDF74C500FA9CCB /* VideoInfoView.swift */; }; + A98EBCEB2FE08E3900FA9CCB /* RevealableSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98EBCEA2FE08E3900FA9CCB /* RevealableSecureField.swift */; }; + A9BB000B2FC506E700683A92 /* PINStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BB000A2FC506E700683A92 /* PINStrings.swift */; }; + A9D60B1B2FC5065C00683A92 /* VideoExportTestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D60B1A2FC5065C00683A92 /* VideoExportTestHelper.swift */; }; + A9D60B1D2FC5067900683A92 /* VideoExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D60B1C2FC5067900683A92 /* VideoExportTests.swift */; }; + A9D60B1F2FC506B600683A92 /* DeveloperToolsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D60B1E2FC506B600683A92 /* DeveloperToolsView.swift */; }; + A9D60B212FC506CE00683A92 /* RunVideoExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D60B202FC506CE00683A92 /* RunVideoExportTests.swift */; }; A9E6B6962E6E47B500BB6F19 /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6B6942E6E47B500BB6F19 /* ThumbnailCache.swift */; }; A9E6B6972E6E47B500BB6F19 /* SecureImageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6B6932E6E47B500BB6F19 /* SecureImageRepository.swift */; }; A9E6B6992E6E47E700BB6F19 /* PhotoDef.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E6B6982E6E47E700BB6F19 /* PhotoDef.swift */; }; @@ -120,9 +161,29 @@ A9F4250E2E94D1840028EB13 /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F4250D2E94D17B0028EB13 /* ZoomableScrollView.swift */; }; A9F425112E95CAB90028EB13 /* DismissPanGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F4250F2E95CAB90028EB13 /* DismissPanGestureHandler.swift */; }; A9F425122E95CAB90028EB13 /* PhotoPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F425102E95CAB90028EB13 /* PhotoPageViewController.swift */; }; + A9F425132E95CAB90028EB13 /* PagerChromeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F425132E95CAB80028EB13 /* PagerChromeState.swift */; }; A9F9DD4A2EA07209003FC66E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F9DD492EA07209003FC66E /* AppDelegate.swift */; }; A9F9DD4E2EA0735A003FC66E /* OrientationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F9DD4D2EA0735A003FC66E /* OrientationManager.swift */; }; A9F9DDA42EA1C980003FC66E /* CameraCaptureIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F9DDA32EA1C980003FC66E /* CameraCaptureIntent.swift */; }; + A9FFC0DE2F3A000100BB6F19 /* VideoDef.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FFC0DE2F3A000000BB6F19 /* VideoDef.swift */; }; + AF250682EF9E0A6D81B711EF /* VideoImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBEA7D1062AABE16019D0AEF /* VideoImportTests.swift */; }; + B11100000000000000000002 /* EncryptedVideoDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000001 /* EncryptedVideoDataSourceTests.swift */; }; + B11100000000000000000004 /* AVPlayerItemStatusObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000003 /* AVPlayerItemStatusObservationTests.swift */; }; + B9D2FCB35A0C40D83FBA3CB8 /* VideoSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC401584FDB751F792E58364 /* VideoSurfaceView.swift */; }; + C0FFEE0000000000000000A2 /* CameraZoomMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000A1 /* CameraZoomMapping.swift */; }; + C0FFEE0000000000000000B2 /* CameraZoomMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000B1 /* CameraZoomMappingTests.swift */; }; + C0FFEE000000000000000401 /* LocationRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000400 /* LocationRepositoryTests.swift */; }; + C0FFEE0000000000000000C2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000C1 /* PrivacyInfo.xcprivacy */; }; + C0FFEE0000000000000000D2 /* CameraPreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000D1 /* CameraPreviewLayout.swift */; }; + C0FFEE0000000000000000E2 /* CameraPreviewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000E1 /* CameraPreviewLayoutTests.swift */; }; + C0FFEE0000000000000000F2 /* EnhancedPhotoDetailViewModelDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0000000000000000F1 /* EnhancedPhotoDetailViewModelDragTests.swift */; }; + C0FFEE000000000000000112 /* MinimumVisibilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000111 /* MinimumVisibilityGate.swift */; }; + C0FFEE000000000000000122 /* MinimumVisibilityGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000121 /* MinimumVisibilityGateTests.swift */; }; + C0FFEE000000000000000201 /* PinStrengthCheckUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000200 /* PinStrengthCheckUseCaseTests.swift */; }; + D54FBF5A0C3BABB963AB33CF /* FakeEncryptionScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2414533D313F8BEF8E1DB17D /* FakeEncryptionScheme.swift */; }; + E81315B178D3FB88663F856F /* FakeVideoEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2AD9082F22CD2A9FC7CD33B /* FakeVideoEncryptionService.swift */; }; + F11C39ACCEDC8B8CAEA2C214 /* PinDEKWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C6DF332A8DDCFFDFA5FDB /* PinDEKWrapperTests.swift */; }; + F994CE57BC4263827C4C1DB9 /* DecoyVideoIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122542F8E8343FD9E2471E5 /* DecoyVideoIntegrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -143,6 +204,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0B07498650554419769A4053 /* HardwareEncryptionSchemeFileProtectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HardwareEncryptionSchemeFileProtectionTests.swift; sourceTree = ""; }; + 0B07498750554419769A4054 /* HardwareEncryptionSchemeSecurityResetTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HardwareEncryptionSchemeSecurityResetTests.swift; sourceTree = ""; }; + 13CBF89B43CD2D2FE8EBA109 /* FileBasedSettingsDataSourceProtectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FileBasedSettingsDataSourceProtectionTests.swift; sourceTree = ""; }; + 2414533D313F8BEF8E1DB17D /* FakeEncryptionScheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeEncryptionScheme.swift; sourceTree = ""; }; + 332C6DF332A8DDCFFDFA5FDB /* PinDEKWrapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PinDEKWrapperTests.swift; sourceTree = ""; }; + 345B31B24DBF8A6CAC9E2617 /* InlineVideoPlayerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayerView.swift; sourceTree = ""; }; + 60C2F7E4B3B5397EF48DF183 /* MediaDetailToolbar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MediaDetailToolbar.swift; sourceTree = ""; }; 660130A82E67753600D07E9C /* AppDependencyInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDependencyInjection.swift; sourceTree = ""; }; 660130B62E67AD1D00D07E9C /* AuthorizationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationRepository.swift; sourceTree = ""; }; 660130B82E67AD1D00D07E9C /* EncryptionScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionScheme.swift; sourceTree = ""; }; @@ -199,7 +267,6 @@ 667FF8282E6CAE0C00FB3E02 /* CombineExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExt.swift; sourceTree = ""; }; 667FF82A2E6CB1C400FB3E02 /* getRotationAngle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = getRotationAngle.swift; sourceTree = ""; }; 667FF82C2E6CC06900FB3E02 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; - 667FF82E2E6CC33B00FB3E02 /* SecureGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureGalleryViewModel.swift; sourceTree = ""; }; 667FF8302E6CD94500FB3E02 /* PINVerificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PINVerificationViewModel.swift; sourceTree = ""; }; 667FF8322E6D0FF800FB3E02 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 667FF8342E6D101300FB3E02 /* AppNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigation.swift; sourceTree = ""; }; @@ -215,6 +282,11 @@ 66A404D42E6800840054FFE7 /* PinRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinRepositoryImpl.swift; sourceTree = ""; }; 66A404D62E694A450054FFE7 /* PinRepositoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinRepositoryTest.swift; sourceTree = ""; }; 66DE21CE2E69750600AC94DA /* Json.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Json.swift; sourceTree = ""; }; + 66FFC0DE2F3A000000C0B617 /* VideoCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCaptureService.swift; sourceTree = ""; }; + 73AE08F5261FA581EF832FE5 /* VerifyPinUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerifyPinUseCaseTests.swift; sourceTree = ""; }; + 9286AA1AF0A4DF1140718E06 /* VideoThumbnailTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VideoThumbnailTests.swift; sourceTree = ""; }; + 9B11F4D9DABB01000AED1127 /* PinDEKWrapper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PinDEKWrapper.swift; sourceTree = ""; }; + A2AD9082F22CD2A9FC7CD33B /* FakeVideoEncryptionService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FakeVideoEncryptionService.swift; sourceTree = ""; }; A91DBB422DE41BAE001F42ED /* SnapSafe.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SnapSafe.xctestplan; sourceTree = ""; }; A91DBC252DE58191001F42ED /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = ""; }; A91DBC262DE58191001F42ED /* DetectedFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedFace.swift; sourceTree = ""; }; @@ -239,7 +311,32 @@ A91DBC502DE58191001F42ED /* SecureGalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureGalleryView.swift; sourceTree = ""; }; A91DBC512DE58191001F42ED /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A91DBC522DE58191001F42ED /* SnapSafeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapSafeApp.swift; sourceTree = ""; }; + A95B2E242F31D19700EE7291 /* SECVFileFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SECVFileFormat.swift; sourceTree = ""; }; + A95B2E282F42F0FC00EE7291 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaItem.swift; path = Models/MediaItem.swift; sourceTree = ""; }; + A95B2E292F42F0FC00EE7291 /* VideoEncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoEncryptionService.swift; path = Encryption/VideoEncryptionService.swift; sourceTree = ""; }; + A95B2E2C2F42F16C00EE7291 /* MixedMediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MixedMediaGalleryViewModel.swift; path = Gallery/MixedMediaGalleryViewModel.swift; sourceTree = ""; }; + A95B2E2E2F42F18F00EE7291 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + A95B2E302F42F1A700EE7291 /* EncryptedVideoDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedVideoDataSource.swift; sourceTree = ""; }; + A98EBC102FDE079B00FA9CCB /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; + A98EBC122FDE07AB00FA9CCB /* ImageProcessingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessingTests.swift; sourceTree = ""; }; + A98EBC1E2FDE170C00FA9CCB /* ImageMetadataTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadataTypes.swift; sourceTree = ""; }; + A98EBC202FDE1ACD00FA9CCB /* PhotoStorageDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoStorageDataSource.swift; sourceTree = ""; }; + A98EBC222FDE1B1300FA9CCB /* PhotoStorageDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoStorageDataSourceTests.swift; sourceTree = ""; }; + A98EBC242FDE4C3B00FA9CCB /* PINEntryField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PINEntryField.swift; sourceTree = ""; }; + A98EBC622FDF673700FA9CCB /* AVMetadataItemFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVMetadataItemFactoryTests.swift; sourceTree = ""; }; + A98EBC642FDF681000FA9CCB /* AVMetadataItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVMetadataItemFactory.swift; sourceTree = ""; }; + A98EBC662FDF6D1500FA9CCB /* VideoMetaDataFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoMetaDataFormattingTests.swift; sourceTree = ""; }; + A98EBC682FDF6DD000FA9CCB /* VideoMetaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoMetaData.swift; sourceTree = ""; }; + A98EBC6A2FDF702B00FA9CCB /* VideoMetaDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoMetaDataTests.swift; sourceTree = ""; }; + A98EBC6C2FDF743100FA9CCB /* VideoInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoInfoViewModel.swift; sourceTree = ""; }; + A98EBC6E2FDF74C500FA9CCB /* VideoInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoInfoView.swift; sourceTree = ""; }; + A98EBCEA2FE08E3900FA9CCB /* RevealableSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevealableSecureField.swift; sourceTree = ""; }; + A9BB000A2FC506E700683A92 /* PINStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PINStrings.swift; sourceTree = ""; }; A9C449132E9CC85800CFE854 /* SnapSafeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SnapSafeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A9D60B1A2FC5065C00683A92 /* VideoExportTestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoExportTestHelper.swift; sourceTree = ""; }; + A9D60B1C2FC5067900683A92 /* VideoExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoExportTests.swift; sourceTree = ""; }; + A9D60B1E2FC506B600683A92 /* DeveloperToolsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperToolsView.swift; sourceTree = ""; }; + A9D60B202FC506CE00683A92 /* RunVideoExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunVideoExportTests.swift; sourceTree = ""; }; A9DE37472DC5F34400679C2C /* SnapSafe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SnapSafe.app; sourceTree = BUILT_PRODUCTS_DIR; }; A9DE37572DC5F34600679C2C /* SnapSafeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SnapSafeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A9E6B6932E6E47B500BB6F19 /* SecureImageRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureImageRepository.swift; sourceTree = ""; }; @@ -253,9 +350,32 @@ A9F4250D2E94D17B0028EB13 /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; A9F4250F2E95CAB90028EB13 /* DismissPanGestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissPanGestureHandler.swift; sourceTree = ""; }; A9F425102E95CAB90028EB13 /* PhotoPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPageViewController.swift; sourceTree = ""; }; + A9F425132E95CAB80028EB13 /* PagerChromeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerChromeState.swift; sourceTree = ""; }; A9F9DD492EA07209003FC66E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A9F9DD4D2EA0735A003FC66E /* OrientationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrientationManager.swift; sourceTree = ""; }; A9F9DDA32EA1C980003FC66E /* CameraCaptureIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureIntent.swift; sourceTree = ""; }; + A9FFC0DE2F3A000000BB6F19 /* VideoDef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDef.swift; sourceTree = ""; }; + ADA2FF82666960557F17548E /* SecureImageRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SecureImageRepositoryTests.swift; sourceTree = ""; }; + AE0EEE6230116B9BC41B148B /* HardwareEncryptionSchemePinBindingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HardwareEncryptionSchemePinBindingTests.swift; sourceTree = ""; }; + B11100000000000000000001 /* EncryptedVideoDataSourceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EncryptedVideoDataSourceTests.swift; sourceTree = ""; }; + B11100000000000000000003 /* AVPlayerItemStatusObservationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AVPlayerItemStatusObservationTests.swift; sourceTree = ""; }; + BC401584FDB751F792E58364 /* VideoSurfaceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VideoSurfaceView.swift; sourceTree = ""; }; + C0FFEE0000000000000000A1 /* CameraZoomMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraZoomMapping.swift; sourceTree = ""; }; + C0FFEE0000000000000000B1 /* CameraZoomMappingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CameraZoomMappingTests.swift; sourceTree = ""; }; + C0FFEE000000000000000400 /* LocationRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationRepositoryTests.swift; sourceTree = ""; }; + C0FFEE0000000000000000C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + C0FFEE0000000000000000D1 /* CameraPreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewLayout.swift; sourceTree = ""; }; + C0FFEE0000000000000000E1 /* CameraPreviewLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CameraPreviewLayoutTests.swift; sourceTree = ""; }; + C0FFEE0000000000000000F1 /* EnhancedPhotoDetailViewModelDragTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EnhancedPhotoDetailViewModelDragTests.swift; sourceTree = ""; }; + C0FFEE000000000000000111 /* MinimumVisibilityGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinimumVisibilityGate.swift; sourceTree = ""; }; + C0FFEE000000000000000121 /* MinimumVisibilityGateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MinimumVisibilityGateTests.swift; sourceTree = ""; }; + C0FFEE000000000000000200 /* PinStrengthCheckUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStrengthCheckUseCaseTests.swift; sourceTree = ""; }; + DBCDFD42CA72A9C8FA98EDCD /* SECVFileFormatTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SECVFileFormatTests.swift; sourceTree = ""; }; + DCC41CA572369E73F5CB7451 /* PoisonPillVideoDeletionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PoisonPillVideoDeletionTests.swift; sourceTree = ""; }; + E122542F8E8343FD9E2471E5 /* DecoyVideoIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DecoyVideoIntegrationTests.swift; sourceTree = ""; }; + E60E8772D487C47F35C819B2 /* AddDecoyVideoUseCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddDecoyVideoUseCase.swift; sourceTree = ""; }; + F10BAC24976F36840D24E6B6 /* OrientationRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrientationRotationTests.swift; sourceTree = ""; }; + FBEA7D1062AABE16019D0AEF /* VideoImportTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VideoImportTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -293,6 +413,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 61044BA7A88D7C3A437AA377 /* Util */ = { + isa = PBXGroup; + children = ( + 2414533D313F8BEF8E1DB17D /* FakeEncryptionScheme.swift */, + A2AD9082F22CD2A9FC7CD33B /* FakeVideoEncryptionService.swift */, + ); + path = Util; + sourceTree = ""; + }; 660130BB2E67AD1D00D07E9C /* Encryption */ = { isa = PBXGroup; children = ( @@ -301,6 +430,7 @@ 660130B82E67AD1D00D07E9C /* EncryptionScheme.swift */, 660130BA2E67AD1D00D07E9C /* PassThroughEncryptionScheme.swift */, 660130C62E67AD3A00D07E9C /* DeviceInfoDataSource.swift */, + 9B11F4D9DABB01000AED1127 /* PinDEKWrapper.swift */, ); path = Encryption; sourceTree = ""; @@ -391,8 +521,11 @@ 6660FC612E8529F900C0B617 /* CameraDeviceService.swift */, 6660FC622E8529F900C0B617 /* CameraFocusService.swift */, 6660FC632E8529F900C0B617 /* CameraPermissionService.swift */, + C0FFEE0000000000000000A1 /* CameraZoomMapping.swift */, + C0FFEE0000000000000000D1 /* CameraPreviewLayout.swift */, 6660FC642E8529F900C0B617 /* CameraZoomService.swift */, 6660FC652E8529F900C0B617 /* PhotoCaptureService.swift */, + 66FFC0DE2F3A000000C0B617 /* VideoCaptureService.swift */, ); path = Services; sourceTree = ""; @@ -400,13 +533,16 @@ 667FF8132E6BAB4500FB3E02 /* Util */ = { isa = PBXGroup; children = ( + A95B2E302F42F1A700EE7291 /* EncryptedVideoDataSource.swift */, 663C7E3C2E71542E00967B9E /* Logging */, 667FF8282E6CAE0C00FB3E02 /* CombineExt.swift */, A9F9DD4D2EA0735A003FC66E /* OrientationManager.swift */, 66DE21CE2E69750600AC94DA /* Json.swift */, 667FF80F2E6A9EE600FB3E02 /* Clock.swift */, + C0FFEE000000000000000111 /* MinimumVisibilityGate.swift */, 66A404CC2E67F0960054FFE7 /* DataExt.swift */, 667FF82A2E6CB1C400FB3E02 /* getRotationAngle.swift */, + A98EBC642FDF681000FA9CCB /* AVMetadataItemFactory.swift */, ); path = Util; sourceTree = ""; @@ -414,6 +550,7 @@ 667FF81D2E6C9DC200FB3E02 /* Screens */ = { isa = PBXGroup; children = ( + A95B2E2C2F42F16C00EE7291 /* MixedMediaGalleryViewModel.swift */, A9F4250B2E9322330028EB13 /* ZoomSliderView.swift */, 6660FC5E2E850E9200C0B617 /* About */, 667FF8342E6D101300FB3E02 /* AppNavigation.swift */, @@ -443,6 +580,8 @@ 6660FC3D2E76952700C0B617 /* PINSetupIntroView.swift */, 667FF8142E6BB00900FB3E02 /* PINSetupViewModel.swift */, A91DBC4C2DE58191001F42ED /* PINSetupView.swift */, + A9BB000A2FC506E700683A92 /* PINStrings.swift */, + A98EBCEA2FE08E3900FA9CCB /* RevealableSecureField.swift */, ); path = PinSetup; sourceTree = ""; @@ -450,7 +589,6 @@ 667FF81F2E6C9E0B00FB3E02 /* Gallery */ = { isa = PBXGroup; children = ( - 667FF82E2E6CC33B00FB3E02 /* SecureGalleryViewModel.swift */, 667FF81A2E6C9D1400FB3E02 /* PhotoCell.swift */, A91DBC502DE58191001F42ED /* SecureGalleryView.swift */, ); @@ -483,6 +621,7 @@ children = ( 667FF8302E6CD94500FB3E02 /* PINVerificationViewModel.swift */, A91DBC4D2DE58191001F42ED /* PINVerificationView.swift */, + A98EBC242FDE4C3B00FA9CCB /* PINEntryField.swift */, ); path = PinVerification; sourceTree = ""; @@ -498,6 +637,8 @@ 667FF8252E6C9EAD00FB3E02 /* Data */ = { isa = PBXGroup; children = ( + A95B2E282F42F0FC00EE7291 /* MediaItem.swift */, + A95B2E292F42F0FC00EE7291 /* VideoEncryptionService.swift */, 660130A82E67753600D07E9C /* AppDependencyInjection.swift */, 6660FC482E77D09200C0B617 /* Authorization */, 660130BB2E67AD1D00D07E9C /* Encryption */, @@ -533,18 +674,30 @@ 663C7E212E6FED9A00967B9E /* RemovePoisonPillIUseCase.swift */, 663C7E222E6FED9A00967B9E /* SecurityResetUseCase.swift */, 663C7E4B2E729DF800967B9E /* VerifyPinUseCase.swift */, + E60E8772D487C47F35C819B2 /* AddDecoyVideoUseCase.swift */, ); path = UseCases; sourceTree = ""; }; + A8CD70FA01E794FBB7CAB2C9 /* Util */ = { + isa = PBXGroup; + children = ( + ); + name = Util; + path = SnapSafeTests/Util; + sourceTree = ""; + }; A91DBC2B2DE58191001F42ED /* Models */ = { isa = PBXGroup; children = ( + A95B2E242F31D19700EE7291 /* SECVFileFormat.swift */, A9E6B69A2E6E487400BB6F19 /* PhotoMetaData.swift */, A9E6B6982E6E47E700BB6F19 /* PhotoDef.swift */, + A9FFC0DE2F3A000000BB6F19 /* VideoDef.swift */, A91DBC252DE58191001F42ED /* AppearanceMode.swift */, A91DBC262DE58191001F42ED /* DetectedFace.swift */, A91DBC272DE58191001F42ED /* MaskMode.swift */, + A98EBC682FDF6DD000FA9CCB /* VideoMetaData.swift */, ); path = Models; sourceTree = ""; @@ -563,6 +716,9 @@ A91DBC312DE58191001F42ED /* PhotoControlsView.swift */, A91DBC322DE58191001F42ED /* ZoomableImageView.swift */, A91DBC332DE58191001F42ED /* ZoomLevelIndicator.swift */, + 60C2F7E4B3B5397EF48DF183 /* MediaDetailToolbar.swift */, + BC401584FDB751F792E58364 /* VideoSurfaceView.swift */, + 345B31B24DBF8A6CAC9E2617 /* InlineVideoPlayerView.swift */, ); path = Components; sourceTree = ""; @@ -578,17 +734,21 @@ A91DBC3C2DE58191001F42ED /* PhotoDetail */ = { isa = PBXGroup; children = ( + A95B2E2E2F42F18F00EE7291 /* VideoPlayerView.swift */, A91DBC342DE58191001F42ED /* Components */, A91DBC362DE58191001F42ED /* Modifiers */, A91DBC372DE58191001F42ED /* EnhancedPhotoDetailView.swift */, A9F4250F2E95CAB90028EB13 /* DismissPanGestureHandler.swift */, A9F425102E95CAB90028EB13 /* PhotoPageViewController.swift */, + A9F425132E95CAB80028EB13 /* PagerChromeState.swift */, A9F4250D2E94D17B0028EB13 /* ZoomableScrollView.swift */, 663C7E2C2E70F2E900967B9E /* EnhancedPhotoDetailViewModel.swift */, A91DBC382DE58191001F42ED /* ImageInfoView.swift */, 663C7E2A2E70EF0C00967B9E /* ImageInfoViewModel.swift */, A91DBC3A2DE58191001F42ED /* PhotoDetailView.swift */, A91DBC3B2DE58191001F42ED /* PhotoDetailViewModel.swift */, + A98EBC6C2FDF743100FA9CCB /* VideoInfoViewModel.swift */, + A98EBC6E2FDF74C500FA9CCB /* VideoInfoView.swift */, ); path = PhotoDetail; sourceTree = ""; @@ -601,10 +761,15 @@ A91DBC4F2DE58191001F42ED /* ScreenCaptureManager.swift */, A91DBC522DE58191001F42ED /* SnapSafeApp.swift */, A91DBC3F2DE58191001F42ED /* Assets.xcassets */, + C0FFEE0000000000000000C1 /* PrivacyInfo.xcprivacy */, 667FF8252E6C9EAD00FB3E02 /* Data */, A91DBC2D2DE58191001F42ED /* Preview Content */, 667FF81D2E6C9DC200FB3E02 /* Screens */, 667FF8132E6BAB4500FB3E02 /* Util */, + A9D60B1A2FC5065C00683A92 /* VideoExportTestHelper.swift */, + A9D60B1C2FC5067900683A92 /* VideoExportTests.swift */, + A9D60B1E2FC506B600683A92 /* DeveloperToolsView.swift */, + A9D60B202FC506CE00683A92 /* RunVideoExportTests.swift */, ); path = SnapSafe; sourceTree = ""; @@ -638,8 +803,36 @@ 667FF8112E6BA7F200FB3E02 /* AuthorizePinUseCaseTests.swift */, 667FF80D2E6A9D2A00FB3E02 /* AuthorizationRepositoryTests.swift */, 6697512F2E69789A0059C5F3 /* TestUtils.swift */, + C0FFEE0000000000000000B1 /* CameraZoomMappingTests.swift */, + C0FFEE000000000000000400 /* LocationRepositoryTests.swift */, + C0FFEE0000000000000000E1 /* CameraPreviewLayoutTests.swift */, + C0FFEE0000000000000000F1 /* EnhancedPhotoDetailViewModelDragTests.swift */, + C0FFEE000000000000000121 /* MinimumVisibilityGateTests.swift */, 66A404D02E67F39F0054FFE7 /* PinCryptoTests.swift */, 66A404D62E694A450054FFE7 /* PinRepositoryTest.swift */, + C0FFEE000000000000000200 /* PinStrengthCheckUseCaseTests.swift */, + ADA2FF82666960557F17548E /* SecureImageRepositoryTests.swift */, + A8CD70FA01E794FBB7CAB2C9 /* Util */, + 61044BA7A88D7C3A437AA377 /* Util */, + DCC41CA572369E73F5CB7451 /* PoisonPillVideoDeletionTests.swift */, + DBCDFD42CA72A9C8FA98EDCD /* SECVFileFormatTests.swift */, + 73AE08F5261FA581EF832FE5 /* VerifyPinUseCaseTests.swift */, + 9286AA1AF0A4DF1140718E06 /* VideoThumbnailTests.swift */, + E122542F8E8343FD9E2471E5 /* DecoyVideoIntegrationTests.swift */, + FBEA7D1062AABE16019D0AEF /* VideoImportTests.swift */, + B11100000000000000000001 /* EncryptedVideoDataSourceTests.swift */, + B11100000000000000000003 /* AVPlayerItemStatusObservationTests.swift */, + 0B07498650554419769A4053 /* HardwareEncryptionSchemeFileProtectionTests.swift */, + 0B07498750554419769A4054 /* HardwareEncryptionSchemeSecurityResetTests.swift */, + 332C6DF332A8DDCFFDFA5FDB /* PinDEKWrapperTests.swift */, + AE0EEE6230116B9BC41B148B /* HardwareEncryptionSchemePinBindingTests.swift */, + 13CBF89B43CD2D2FE8EBA109 /* FileBasedSettingsDataSourceProtectionTests.swift */, + F10BAC24976F36840D24E6B6 /* OrientationRotationTests.swift */, + A98EBC122FDE07AB00FA9CCB /* ImageProcessingTests.swift */, + A98EBC222FDE1B1300FA9CCB /* PhotoStorageDataSourceTests.swift */, + A98EBC622FDF673700FA9CCB /* AVMetadataItemFactoryTests.swift */, + A98EBC662FDF6D1500FA9CCB /* VideoMetaDataFormattingTests.swift */, + A98EBC6A2FDF702B00FA9CCB /* VideoMetaDataTests.swift */, ); path = SnapSafeTests; sourceTree = ""; @@ -649,6 +842,9 @@ children = ( A9E6B6932E6E47B500BB6F19 /* SecureImageRepository.swift */, A9E6B6942E6E47B500BB6F19 /* ThumbnailCache.swift */, + A98EBC102FDE079B00FA9CCB /* ImageProcessing.swift */, + A98EBC1E2FDE170C00FA9CCB /* ImageMetadataTypes.swift */, + A98EBC202FDE1ACD00FA9CCB /* PhotoStorageDataSource.swift */, ); path = SecureImage; sourceTree = ""; @@ -673,8 +869,6 @@ A9C449142E9CC85800CFE854 /* SnapSafeUITests */, ); name = SnapSafeUITests; - packageProductDependencies = ( - ); productName = SnapSafeUITests; productReference = A9C449132E9CC85800CFE854 /* SnapSafeUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; @@ -732,7 +926,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 2600; TargetAttributes = { A9C449122E9CC85800CFE854 = { CreatedOnToolsVersion = 26.0.1; @@ -788,6 +982,7 @@ files = ( A91DBC7A2DE58191001F42ED /* Preview Assets.xcassets in Resources */, A91DBC7B2DE58191001F42ED /* Assets.xcassets in Resources */, + C0FFEE0000000000000000C2 /* PrivacyInfo.xcprivacy in Resources */, A9E6B6B72E7247D300BB6F19 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -807,6 +1002,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A95B2E272F31D19700EE7291 /* SECVFileFormat.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -819,9 +1015,14 @@ 663C7E542E73FA3100967B9E /* PoisonPillSetupWizardViewModel.swift in Sources */, 6660FC672E8529F900C0B617 /* CameraPermissionService.swift in Sources */, 6660FC682E8529F900C0B617 /* PhotoCaptureService.swift in Sources */, + 66FFC0DE2F3A000100C0B617 /* VideoCaptureService.swift in Sources */, 6660FC692E8529F900C0B617 /* CameraDeviceService.swift in Sources */, + A9D60B1F2FC506B600683A92 /* DeveloperToolsView.swift in Sources */, + C0FFEE0000000000000000A2 /* CameraZoomMapping.swift in Sources */, + C0FFEE0000000000000000D2 /* CameraPreviewLayout.swift in Sources */, 6660FC6A2E8529F900C0B617 /* CameraZoomService.swift in Sources */, 6660FC6B2E8529F900C0B617 /* CameraFocusService.swift in Sources */, + A98EBC6F2FDF74C500FA9CCB /* VideoInfoView.swift in Sources */, 663C7E552E73FA3100967B9E /* PoisonPillPinCreationView.swift in Sources */, 663C7E562E73FA3100967B9E /* PoisonPillSetupWizardView.swift in Sources */, 663C7E572E73FA3100967B9E /* PoisonPillExplanationView.swift in Sources */, @@ -829,16 +1030,22 @@ 663C7E232E6FED9A00967B9E /* SecurityResetUseCase.swift in Sources */, 663C7E2D2E70F2E900967B9E /* EnhancedPhotoDetailViewModel.swift in Sources */, 663C7E242E6FED9A00967B9E /* RemovePoisonPillIUseCase.swift in Sources */, + A98EBC212FDE1ACD00FA9CCB /* PhotoStorageDataSource.swift in Sources */, A9F425112E95CAB90028EB13 /* DismissPanGestureHandler.swift in Sources */, A9F425122E95CAB90028EB13 /* PhotoPageViewController.swift in Sources */, + A9F425132E95CAB90028EB13 /* PagerChromeState.swift in Sources */, 663C7E252E6FED9A00967B9E /* InvalidateSessionUseCase.swift in Sources */, 663C7E262E6FED9A00967B9E /* AddDecoyPhotoUseCase.swift in Sources */, 663C7E272E6FED9A00967B9E /* PinStrengthCheckUseCase.swift in Sources */, A9F9DD4E2EA0735A003FC66E /* OrientationManager.swift in Sources */, 6660FC452E77CE4B00C0B617 /* RemoveDecoyPhotoUseCase.swift in Sources */, A9E6B6992E6E47E700BB6F19 /* PhotoDef.swift in Sources */, + A9FFC0DE2F3A000100BB6F19 /* VideoDef.swift in Sources */, + A95B2E312F42F1A700EE7291 /* EncryptedVideoDataSource.swift in Sources */, 667FF83D2E6D16C700FB3E02 /* CameraContainerView.swift in Sources */, A91DBC552DE58191001F42ED /* DetectedFace.swift in Sources */, + A95B2E2A2F42F0FC00EE7291 /* MediaItem.swift in Sources */, + A95B2E2B2F42F0FC00EE7291 /* VideoEncryptionService.swift in Sources */, 667FF82D2E6CC06900FB3E02 /* SettingsViewModel.swift in Sources */, 663C7E2F2E71121C00967B9E /* PrepareForSharingUseCase.swift in Sources */, A91DBC562DE58191001F42ED /* MaskMode.swift in Sources */, @@ -849,10 +1056,14 @@ 663C7E2B2E70EF0C00967B9E /* ImageInfoViewModel.swift in Sources */, A91DBC5D2DE58191001F42ED /* PhotoControlsView.swift in Sources */, 667FF8102E6A9EE600FB3E02 /* Clock.swift in Sources */, + C0FFEE000000000000000112 /* MinimumVisibilityGate.swift in Sources */, 6660FC3F2E76952700C0B617 /* PINSetupIntroView.swift in Sources */, + A98EBC652FDF681000FA9CCB /* AVMetadataItemFactory.swift in Sources */, 660130A92E67753600D07E9C /* AppDependencyInjection.swift in Sources */, A91DBC5E2DE58191001F42ED /* ZoomableImageView.swift in Sources */, + A9D60B1B2FC5065C00683A92 /* VideoExportTestHelper.swift in Sources */, 667FF8172E6C9C9B00FB3E02 /* CameraView.swift in Sources */, + A9D60B1D2FC5067900683A92 /* VideoExportTests.swift in Sources */, 667FF8292E6CAE1000FB3E02 /* CombineExt.swift in Sources */, 667FF8152E6BB00900FB3E02 /* PINSetupViewModel.swift in Sources */, 667FF8192E6C9CF600FB3E02 /* UIImageExt.swift in Sources */, @@ -870,13 +1081,14 @@ 667FF8332E6D0FF800FB3E02 /* ContentView.swift in Sources */, 663C7E4E2E73DB3100967B9E /* CreatePoisonPillUseCase.swift in Sources */, A91DBC612DE58191001F42ED /* EnhancedPhotoDetailView.swift in Sources */, - 667FF82F2E6CC33B00FB3E02 /* SecureGalleryViewModel.swift in Sources */, 667FF81B2E6C9D1800FB3E02 /* PhotoCell.swift in Sources */, A91DBC622DE58191001F42ED /* ImageInfoView.swift in Sources */, A9E6B6B12E6EAE3500BB6F19 /* SecurityOverlayView.swift in Sources */, 66A404CD2E67F0960054FFE7 /* DataExt.swift in Sources */, A9F9DD4A2EA07209003FC66E /* AppDelegate.swift in Sources */, + A95B2E2D2F42F16C00EE7291 /* MixedMediaGalleryViewModel.swift in Sources */, 663C7E312E712E9000967B9E /* HardwareEncryptionScheme.swift in Sources */, + A98EBC6D2FDF743100FA9CCB /* VideoInfoViewModel.swift in Sources */, 663C7E3D2E71542E00967B9E /* LoggingConfiguration.swift in Sources */, 663C7E3E2E71542E00967B9E /* Logger+Extensions.swift in Sources */, A9E6B6962E6E47B500BB6F19 /* ThumbnailCache.swift in Sources */, @@ -894,16 +1106,24 @@ 660130C72E67AD3A00D07E9C /* DeviceInfoDataSource.swift in Sources */, A9F4250C2E9322330028EB13 /* ZoomSliderView.swift in Sources */, 669751332E6A63D30059C5F3 /* AuthorizePinUseCase.swift in Sources */, + A98EBCEB2FE08E3900FA9CCB /* RevealableSecureField.swift in Sources */, 663C7E292E6FEE2500967B9E /* CameraViewModel.swift in Sources */, + A95B2E2F2F42F18F00EE7291 /* VideoPlayerView.swift in Sources */, 66A404CB2E67EB7F0054FFE7 /* PinCrypto.swift in Sources */, + A98EBC112FDE079C00FA9CCB /* ImageProcessing.swift in Sources */, + A98EBC692FDF6DD000FA9CCB /* VideoMetaData.swift in Sources */, A91DBC702DE58191001F42ED /* LocationRepository.swift in Sources */, A9E6B6AF2E6EAD3D00BB6F19 /* SecurityOverlayViewModel.swift in Sources */, A91DBC732DE58191001F42ED /* PINSetupView.swift in Sources */, + A9BB000B2FC506E700683A92 /* PINStrings.swift in Sources */, 669751352E6A64330059C5F3 /* CreatePinUseCase.swift in Sources */, A91DBC742DE58191001F42ED /* PINVerificationView.swift in Sources */, + A95B2E262F31D19700EE7291 /* SECVFileFormat.swift in Sources */, A91DBC752DE58191001F42ED /* PrivacyShield.swift in Sources */, 660130BC2E67AD1D00D07E9C /* AuthorizationRepository.swift in Sources */, 660130BE2E67AD1D00D07E9C /* EncryptionScheme.swift in Sources */, + A9D60B212FC506CE00683A92 /* RunVideoExportTests.swift in Sources */, + A98EBC252FDE4C3B00FA9CCB /* PINEntryField.swift in Sources */, 660130BF2E67AD1D00D07E9C /* HashedPin.swift in Sources */, 660130C02E67AD1D00D07E9C /* PassThroughEncryptionScheme.swift in Sources */, 6660FC4E2E83736200C0B617 /* FileBasedSettingsDataSource.swift in Sources */, @@ -914,6 +1134,12 @@ A91DBC772DE58191001F42ED /* SecureGalleryView.swift in Sources */, A91DBC782DE58191001F42ED /* SettingsView.swift in Sources */, A91DBC792DE58191001F42ED /* SnapSafeApp.swift in Sources */, + 06380B44AA837F59C33FFAF0 /* AddDecoyVideoUseCase.swift in Sources */, + 113AED184D13916EBB009C93 /* MediaDetailToolbar.swift in Sources */, + B9D2FCB35A0C40D83FBA3CB8 /* VideoSurfaceView.swift in Sources */, + 0A39B5BB99D38FD752C33D40 /* InlineVideoPlayerView.swift in Sources */, + A98EBC1F2FDE170C00FA9CCB /* ImageMetadataTypes.swift in Sources */, + 38579EABF27707E732CDC069 /* PinDEKWrapper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -926,7 +1152,36 @@ 667FF80E2E6A9D3000FB3E02 /* AuthorizationRepositoryTests.swift in Sources */, 6660FC6F2E8BB41600C0B617 /* ShardedKeyTests.swift in Sources */, 669751302E69789F0059C5F3 /* TestUtils.swift in Sources */, + A95B2E252F31D19700EE7291 /* SECVFileFormat.swift in Sources */, 66A404D72E694A450054FFE7 /* PinRepositoryTest.swift in Sources */, + C0FFEE000000000000000201 /* PinStrengthCheckUseCaseTests.swift in Sources */, + D54FBF5A0C3BABB963AB33CF /* FakeEncryptionScheme.swift in Sources */, + C0FFEE0000000000000000B2 /* CameraZoomMappingTests.swift in Sources */, + C0FFEE000000000000000401 /* LocationRepositoryTests.swift in Sources */, + A98EBC132FDE07AB00FA9CCB /* ImageProcessingTests.swift in Sources */, + C0FFEE0000000000000000E2 /* CameraPreviewLayoutTests.swift in Sources */, + A98EBC232FDE1B1300FA9CCB /* PhotoStorageDataSourceTests.swift in Sources */, + C0FFEE0000000000000000F2 /* EnhancedPhotoDetailViewModelDragTests.swift in Sources */, + C0FFEE000000000000000122 /* MinimumVisibilityGateTests.swift in Sources */, + 68109942731A0033DBA31CA8 /* PoisonPillVideoDeletionTests.swift in Sources */, + A98EBC672FDF6D1500FA9CCB /* VideoMetaDataFormattingTests.swift in Sources */, + 71A1063EE417231D3E6A771B /* SECVFileFormatTests.swift in Sources */, + 78BAE12E96629EA55F066179 /* SecureImageRepositoryTests.swift in Sources */, + 7CBC61415276C81597CDBF80 /* VerifyPinUseCaseTests.swift in Sources */, + E81315B178D3FB88663F856F /* FakeVideoEncryptionService.swift in Sources */, + 182F66A484EDD7D5670EBE15 /* VideoThumbnailTests.swift in Sources */, + F994CE57BC4263827C4C1DB9 /* DecoyVideoIntegrationTests.swift in Sources */, + AF250682EF9E0A6D81B711EF /* VideoImportTests.swift in Sources */, + B11100000000000000000002 /* EncryptedVideoDataSourceTests.swift in Sources */, + B11100000000000000000004 /* AVPlayerItemStatusObservationTests.swift in Sources */, + 24194F171D3CBDF42B72D556 /* HardwareEncryptionSchemeFileProtectionTests.swift in Sources */, + 24194F181D3CBDF42B72D557 /* HardwareEncryptionSchemeSecurityResetTests.swift in Sources */, + F11C39ACCEDC8B8CAEA2C214 /* PinDEKWrapperTests.swift in Sources */, + A98EBC6B2FDF702B00FA9CCB /* VideoMetaDataTests.swift in Sources */, + A98EBC632FDF673700FA9CCB /* AVMetadataItemFactoryTests.swift in Sources */, + 33145A757800B951872791FC /* HardwareEncryptionSchemePinBindingTests.swift in Sources */, + 86FA0BDF73A263C07D744E4D /* FileBasedSettingsDataSourceProtectionTests.swift in Sources */, + 6D125407D63ACE7CF6CB74FE /* OrientationRotationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -958,11 +1213,15 @@ PRODUCT_BUNDLE_IDENTIFIER = com.darkrockstudios.apps.snapsafe.SnapSafeUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = SnapSafe; }; name = Debug; @@ -979,11 +1238,15 @@ PRODUCT_BUNDLE_IDENTIFIER = com.darkrockstudios.apps.snapsafe.SnapSafeUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_TARGET_NAME = SnapSafe; }; name = Release; @@ -1046,6 +1309,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1102,6 +1366,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -1116,17 +1381,19 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"SnapSafe/Preview Content\""; - DEVELOPMENT_TEAM = 8P3G3HT4J5; + DEVELOPMENT_TEAM = BP75F4S5N3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Snap-Safe-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = SnapSafe; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; - INFOPLIST_KEY_NSCameraUsageDescription = "This app needs camera access so you can take photos that are encrypted and stored locally."; + INFOPLIST_KEY_NSCameraUsageDescription = "This app needs camera access so you can take photos and videos that are encrypted and stored locally."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This lets the app tag your photos with where they were taken."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app needs microphone access to record audio with your videos."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -1134,7 +1401,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.darkrockstudios.apps.snapsafe; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1145,7 +1412,7 @@ "SWIFT_ACTIVE_COMPILATION_CONDITIONS[arch=*]" = "$(inherited) MOCKING"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -1166,11 +1433,13 @@ INFOPLIST_FILE = "Snap-Safe-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = SnapSafe; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; - INFOPLIST_KEY_NSCameraUsageDescription = "This app needs camera access so you can take photos that are encrypted and stored locally."; + INFOPLIST_KEY_NSCameraUsageDescription = "This app needs camera access so you can take photos and videos that are encrypted and stored locally."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This lets the app tag your photos with where they were taken."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app needs microphone access to record audio with your videos."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 18.5; @@ -1178,7 +1447,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.darkrockstudios.apps.snapsafe; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1189,7 +1458,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -1205,9 +1474,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = snapsafe.SnapSafeTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SnapSafe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SnapSafe"; }; name = Debug; @@ -1218,15 +1491,19 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8P3G3HT4J5; + DEVELOPMENT_TEAM = BP75F4S5N3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = snapsafe.SnapSafeTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SnapSafe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SnapSafe"; }; name = Release; diff --git a/SnapSafe.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/SnapSafe.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/SnapSafe.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/SnapSafe.xcodeproj/project.xcworkspace/xcuserdata/bill.xcuserdatad/WorkspaceSettings.xcsettings b/SnapSafe.xcodeproj/project.xcworkspace/xcuserdata/bill.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/SnapSafe.xcodeproj/project.xcworkspace/xcuserdata/bill.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/SnapSafe.xcodeproj/xcshareddata/xcschemes/SnapSafe.xcscheme b/SnapSafe.xcodeproj/xcshareddata/xcschemes/SnapSafe.xcscheme index 2b0f432..2d28791 100644 --- a/SnapSafe.xcodeproj/xcshareddata/xcschemes/SnapSafe.xcscheme +++ b/SnapSafe.xcodeproj/xcshareddata/xcschemes/SnapSafe.xcscheme @@ -1,6 +1,6 @@ diff --git a/SnapSafe.xcworkspace/contents.xcworkspacedata b/SnapSafe.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..311c4e0 --- /dev/null +++ b/SnapSafe.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SnapSafe/CameraCaptureIntent.swift b/SnapSafe/CameraCaptureIntent.swift index 01322c8..3b4135c 100644 --- a/SnapSafe/CameraCaptureIntent.swift +++ b/SnapSafe/CameraCaptureIntent.swift @@ -11,14 +11,12 @@ import SwiftUI @available(iOS 18.0, *) struct CameraCaptureIntent: AppIntent { static let title: LocalizedStringResource = "Open Camera" + // periphery:ignore static let description = IntentDescription("Opens SnapSafe camera to capture photos securely") // Make this intent available for Action Button and Control Center static let openAppWhenRun: Bool = true - // Add icon for the intent - static let systemImageName: String = "camera" - func perform() async throws -> some IntentResult { // Post a notification to open the camera await MainActor.run { diff --git a/SnapSafe/Data/AppDependencyInjection.swift b/SnapSafe/Data/AppDependencyInjection.swift index 646c975..a9d6ecd 100644 --- a/SnapSafe/Data/AppDependencyInjection.swift +++ b/SnapSafe/Data/AppDependencyInjection.swift @@ -7,49 +7,11 @@ import Foundation import FactoryKit -import Logging import SwiftUI extension Container { - - // MARK: - Logging - - /// Factory for encryption logger - var encryptionLogger: Factory { - self { Logger.encryption } - } - - /// Factory for security logger - var securityLogger: Factory { - self { Logger.security } - } - - /// Factory for camera logger - var cameraLogger: Factory { - self { Logger.camera } - } - - /// Factory for storage logger - var storageLogger: Factory { - self { Logger.storage } - } - - /// Factory for UI logger - var uiLogger: Factory { - self { Logger.ui } - } - - /// Factory for app logger - var appLogger: Factory { - self { Logger.app } - } - - /// Factory for creating subsystem loggers - func logger(subsystem: String, category: String) -> Logger { - return Logger.subsystem(subsystem, category: category) - } - + // MARK: - Core Dependencies var clock: Factory { @@ -133,19 +95,18 @@ extension Container { self { LocationRepository() }.singleton } - @MainActor var thumbnailCache: Factory { - self { @MainActor in ThumbnailCache() }.singleton + self { ThumbnailCache() }.singleton } - - @MainActor + var secureImageRepository: Factory { - self { @MainActor in SecureImageRepository( + self { SecureImageRepository( thumbnailCache: self.thumbnailCache(), - encryptionScheme: self.encryptionScheme() + encryptionScheme: self.encryptionScheme(), + videoEncryptionService: self.videoEncryptionService() ) }.singleton } - + @MainActor var addDecoyPhotoUseCase: Factory { self { @MainActor in AddDecoyPhotoUseCase( @@ -154,6 +115,15 @@ extension Container { imageRepository: self.secureImageRepository() ) } } + + @MainActor + var addDecoyVideoUseCase: Factory { + self { @MainActor in AddDecoyVideoUseCase( + pinRepository: self.pinRepository(), + encryptionScheme: self.encryptionScheme(), + imageRepository: self.secureImageRepository() + ) } + } @MainActor var removeDecoyPhotoUseCase: Factory { @@ -198,4 +168,10 @@ extension Container { authManager: self.authorizationRepository(), ) } } + + // MARK: - Video + + var videoEncryptionService: Factory { + self { VideoEncryptionService() }.shared + } } diff --git a/SnapSafe/Data/Authorization/AuthorizationRepository.swift b/SnapSafe/Data/Authorization/AuthorizationRepository.swift index c22e93b..56eea5c 100644 --- a/SnapSafe/Data/Authorization/AuthorizationRepository.swift +++ b/SnapSafe/Data/Authorization/AuthorizationRepository.swift @@ -11,9 +11,16 @@ import FactoryKit /// Manages user authorization state, including PIN verification and session expiration. -public final class AuthorizationRepository: @unchecked Sendable { +/// +/// `@MainActor`-isolated so the mutable session/auth state (the authorization flag +/// and the monotonic timestamp baselines) is only ever touched on one actor. This +/// replaces a previous `@unchecked Sendable`, which silenced the data-race +/// diagnostic without actually serializing access. A `nonisolated init` keeps the +/// type constructible from the non-isolated DI factory closures. +@MainActor +final class AuthorizationRepository { // MARK: - Constants - public static let MAX_FAILED_ATTEMPTS = 10 + static let MAX_FAILED_ATTEMPTS = 10 // MARK: - Dependencies private let appSettings: SettingsDataSource @@ -22,16 +29,20 @@ public final class AuthorizationRepository: @unchecked Sendable { // MARK: - Auth state (StateFlow -> Combine) @Published private var isAuthorizedValue: Bool = false - public var isAuthorized: AnyPublisher { + var isAuthorized: AnyPublisher { $isAuthorizedValue.eraseToAnyPublisher() } // MARK: - Timestamps - private var lastAuthTime: Date = .distantPast - private var lastKeepAlive: Date = .distantPast + // Monotonic baselines for elapsed-time decisions. Using wall-clock here would + // let an attacker bypass session timeout or PIN backoff by changing the device + // clock. `nil` means "not set in this process lifetime". + private var lastAuthMonotonic: TimeInterval? + private var lastKeepAliveMonotonic: TimeInterval? + private var lastFailedMonotonic: TimeInterval? // MARK: - Init - public init( + nonisolated init( settings: SettingsDataSource, encryptionScheme: EncryptionScheme, clock: Clock @@ -42,40 +53,43 @@ public final class AuthorizationRepository: @unchecked Sendable { } // MARK: - Security reset - public func securityFailureReset() async { + func securityFailureReset() async { await appSettings.securityFailureReset() } // MARK: - Failed attempts /// Gets the current number of failed PIN attempts - public func getFailedAttempts() async -> Int { + func getFailedAttempts() async -> Int { await appSettings.getFailedPinAttempts() } /// Sets the number of failed PIN attempts - public func setFailedAttempts(_ count: Int) async { + func setFailedAttempts(_ count: Int) async { await appSettings.setFailedPinAttempts(count) } /// Increments failed attempts, stores the current timestamp, and returns the new count - public func incrementFailedAttempts() async -> Int { - let current = await getFailedAttempts() - let newCount = current + 1 - await setFailedAttempts(newCount) + func incrementFailedAttempts() async -> Int { + // Delegate the increment to the data source, which performs the + // read-modify-write atomically. Doing it here as `read; await; write` spanned + // two suspension points, so concurrent verification attempts could read the + // same count and lose an increment — undercounting the lockout (TOCTOU). + let newCount = await appSettings.incrementFailedPinAttempts() let nowMs = Int64(clock.now.timeIntervalSince1970 * 1000.0) await appSettings.setLastFailedAttemptTimestamp(nowMs) + lastFailedMonotonic = clock.monotonicNow return newCount } /// Gets the timestamp (ms since epoch) of the last failed attempt - public func getLastFailedAttemptTimestamp() async -> Int64 { + func getLastFailedAttemptTimestamp() async -> Int64 { await appSettings.getLastFailedAttemptTimestamp() } /// Calculates remaining backoff in seconds based on failed attempts and last failed timestamp - public func calculateRemainingBackoffSeconds() async -> Int { + func calculateRemainingBackoffSeconds() async -> Int { let failedAttempts = await getFailedAttempts() guard failedAttempts > 1 else { return 0 } @@ -84,21 +98,32 @@ public final class AuthorizationRepository: @unchecked Sendable { let backoffSeconds = Int(pow(2.0, Double(failedAttempts - 1))) - let nowMs = Int64(clock.now.timeIntervalSince1970 * 1000.0) - let elapsedSeconds = Int((nowMs - lastFailed) / 1000) + let elapsedSeconds: Int + if let baseline = lastFailedMonotonic { + elapsedSeconds = Int(clock.monotonicNow - baseline) + } else { + // Process restarted since the failed attempt was recorded; the + // monotonic baseline is gone. Fall back to wall clock but clamp + // negative deltas to 0 so a backward clock change can't shorten + // the remaining backoff. + let nowMs = Int64(clock.now.timeIntervalSince1970 * 1000.0) + let delta = nowMs - lastFailed + elapsedSeconds = max(0, Int(delta / 1000)) + } let remaining = backoffSeconds - elapsedSeconds return max(0, remaining) } /// Resets failed attempts and clears last failed timestamp - public func resetFailedAttempts() async { + func resetFailedAttempts() async { await setFailedAttempts(0) await appSettings.setLastFailedAttemptTimestamp(0) + lastFailedMonotonic = nil } // MARK: - Initial key creation - public func createKey(pin: String, hashedPin: HashedPin) async -> Bool { + func createKey(pin: String, hashedPin: HashedPin) async -> Bool { do { try await encryptionScheme.createKey(plainPin: pin, hashedPin: hashedPin) return true @@ -110,28 +135,31 @@ public final class AuthorizationRepository: @unchecked Sendable { // MARK: - Session lifecycle /// Marks the session as authorized and updates the last authentication time. /// Also starts session monitoring. - public func authorizeSession() { - lastAuthTime = clock.now + func authorizeSession() { + lastAuthMonotonic = clock.monotonicNow isAuthorizedValue = true } /// Updates the keep-alive timestamp to extend the session validity /// without requiring re-authentication. - public func keepAliveSession() { + func keepAliveSession() { if isAuthorizedValue { - lastKeepAlive = clock.now + lastKeepAliveMonotonic = clock.monotonicNow } } /// Checks if the current session is still valid or has expired. - public func checkSessionValidity() async -> Bool { + func checkSessionValidity() async -> Bool { guard isAuthorizedValue else { return false } let timeoutMs = await appSettings.getSessionTimeout() // Int64 (ms) - // Prefer the keep-alive time if present; else the last auth time - let pivot: Date = (lastKeepAlive > .distantPast) ? lastKeepAlive : lastAuthTime - let elapsedMs = clock.now.timeIntervalSince(pivot) * 1000.0 + // Prefer the keep-alive time if present; else the last auth time. + // Both are monotonic so the wall clock can't influence expiry. + guard let pivot = lastKeepAliveMonotonic ?? lastAuthMonotonic else { + return false + } + let elapsedMs = (clock.monotonicNow - pivot) * 1000.0 let sessionValid = elapsedMs < Double(timeoutMs) if !sessionValid { @@ -142,9 +170,9 @@ public final class AuthorizationRepository: @unchecked Sendable { } /// Explicitly revokes the current authorization session. - public func revokeAuthorization() { + func revokeAuthorization() { isAuthorizedValue = false - lastAuthTime = .distantPast - lastKeepAlive = .distantPast + lastAuthMonotonic = nil + lastKeepAliveMonotonic = nil } } diff --git a/SnapSafe/Data/Encryption/EncryptionScheme.swift b/SnapSafe/Data/Encryption/EncryptionScheme.swift index 3b6c389..41a39ba 100644 --- a/SnapSafe/Data/Encryption/EncryptionScheme.swift +++ b/SnapSafe/Data/Encryption/EncryptionScheme.swift @@ -11,7 +11,7 @@ import Mockable /// Encryption schemes used to encrypt and decrypt files. /// You can provide concrete implementations, e.g. Software / Hardware. @Mockable -public protocol EncryptionScheme: Sendable { +protocol EncryptionScheme: Sendable { // MARK: - Encrypt to file (derived key in cache) /// Encrypts plaintext data and writes it to a file using the pre-derived key in cache. func encryptToFile(plain: Data, targetFile: URL) async throws @@ -22,6 +22,7 @@ public protocol EncryptionScheme: Sendable { // MARK: - Encrypt / Decrypt with explicit key or key alias /// Encrypts plaintext using the provided key bytes and returns the ciphertext. + // periphery:ignore func encrypt(plain: Data, keyBytes: Data) async throws -> Data /// Encrypts plaintext using the provided key alias and returns the ciphertext. @@ -44,8 +45,10 @@ public protocol EncryptionScheme: Sendable { /// Derives (but does not necessarily cache) a key from the provided PIN. func deriveKey(plainPin: String, hashedPin: HashedPin) async throws -> Data - /// Evicts any cached/derived key from memory. - func evictKey() + /// Evicts any cached/derived key from memory. Callers must await so the + /// key is guaranteed cleared before they proceed (e.g. before signaling + /// a completed security reset). + func evictKey() async // MARK: - First-time key creation & resets /// First-time key creation bootstrap. diff --git a/SnapSafe/Data/Encryption/HardwareEncryptionScheme.swift b/SnapSafe/Data/Encryption/HardwareEncryptionScheme.swift index 79f011e..5bd6dea 100644 --- a/SnapSafe/Data/Encryption/HardwareEncryptionScheme.swift +++ b/SnapSafe/Data/Encryption/HardwareEncryptionScheme.swift @@ -20,7 +20,12 @@ private actor KeyCache { } func getKey() -> Data? { - return try! shardedKey?.reconstructKey() + do { + return try shardedKey?.reconstructKey() + } catch { + Logger.security.error("Failed to reconstruct sharded key: \(error)") + return nil + } } func setKey(_ key: Data) { @@ -42,24 +47,26 @@ private actor KeyCache { final class HardwareEncryptionScheme: EncryptionScheme { // MARK: - Constants - private static let keyAlias = "snapsafe_kek" - private static let aesGCMMode = "AES/GCM/NoPadding" + private static let defaultKeyAlias = "snapsafe_kek" private static let ivLengthBytes = 12 // 96-bit IV recommended for GCM private static let tagLengthBits = 128 // 128-bit tag appended automatically - private static let dSaltSize = 64 private static let dekFilenamePrefix = "dek" private static let dekDirectory = "keys" - private static let defaultIterations: UInt32 = 600_000 // PBKDF2 iterations private static let defaultKeySize = 32 // 256-bit keys - + // MARK: - Dependencies private let deviceInfo: DeviceInfoDataSource private let keyCache = KeyCache() private let logger = Logger.encryption - + + /// The hardware KEK alias. Injectable so tests can use an isolated alias and + /// never touch the production `snapsafe_kek` in a shared (on-device) keychain. + private let keyAlias: String + // MARK: - Initialization - init(deviceInfo: DeviceInfoDataSource) { + init(deviceInfo: DeviceInfoDataSource, keyAlias: String = HardwareEncryptionScheme.defaultKeyAlias) { self.deviceInfo = deviceInfo + self.keyAlias = keyAlias } // MARK: - EncryptionScheme Protocol Implementation @@ -93,7 +100,12 @@ final class HardwareEncryptionScheme: EncryptionScheme { } func decryptWithKeyAlias(encrypted: Data, keyAlias: String) async throws -> Data { - try createHardwareKeyIfNeeded(keyAlias: keyAlias) + // Do NOT create a key on the decrypt path. If the key is missing or + // inaccessible (e.g. a device migration drops the Secure-Enclave key, or the + // keychain access group changed), surface the failure so callers can treat it + // as a recoverable "secure key unavailable" state. Silently minting a new key + // here permanently shadows the original and makes all existing ciphertext + // undecryptable — the exact cause of the PIN-upgrade lockout. return try decryptWithHardwareKey(encrypted: encrypted, keyAlias: keyAlias) } @@ -151,23 +163,21 @@ final class HardwareEncryptionScheme: EncryptionScheme { return try await deriveWrappedKey(plainPin: plainPin, hashedPin: hashedPin) } - func evictKey() { - Task { - await keyCache.evictKey() - } + func evictKey() async { + await keyCache.evictKey() } func createKey(plainPin: String, hashedPin: HashedPin) async throws { try await logger.logAsyncOperation("create_key") { // Create hardware-backed KEK if it doesn't exist (outside of lock) - if !hardwareKeyExists(keyAlias: Self.keyAlias) { + if !hardwareKeyExists(keyAlias: self.keyAlias) { logger.info("Hardware key doesn't exist, creating new one", metadata: [ - "key_alias": .string(Self.keyAlias) + "key_alias": .string(self.keyAlias) ]) - try createHardwareKey(keyAlias: Self.keyAlias) + try createHardwareKey(keyAlias: self.keyAlias) } else { logger.debug("Hardware key already exists", metadata: [ - "key_alias": .string(Self.keyAlias) + "key_alias": .string(self.keyAlias) ]) } @@ -177,27 +187,41 @@ final class HardwareEncryptionScheme: EncryptionScheme { func securityFailureReset() async { logger.warning("Performing security failure reset") - - // Delete all DEKs + + // 1. Evict any in-memory derived key. Must be awaited so the cache is + // guaranteed empty before reset returns — otherwise an attacker who + // triggered the reset by racing the device-lock state could observe + // the key still cached momentarily after reset. + await evictKey() + + // 2. Delete hardware-backed key material (Secure Enclave / keychain). + // Without this, the EC keys (snapsafe_kek, pin_key, ...) survive + // reset and can decrypt any DEK that ever leaks via backup/extraction. + let deletedKeyCount = deleteAllHardwareKeys() + logger.info("Deleted hardware keys", metadata: [ + "count": .stringConvertible(deletedKeyCount) + ]) + + // 3. Delete all DEKs on disk let keyDir = getKeyDirectory() do { let contents = try FileManager.default.contentsOfDirectory(at: keyDir, includingPropertiesForKeys: nil) let dekFiles = contents.filter { file in file.lastPathComponent.hasPrefix(Self.dekFilenamePrefix) } - + logger.info("Found DEK files to delete", metadata: [ "file_count": .stringConvertible(dekFiles.count), "directory": .string(keyDir.lastPathComponent) ]) - + for file in dekFiles { try FileManager.default.removeItem(at: file) logger.debug("Deleted DEK file", metadata: [ "file": .string(file.lastPathComponent) ]) } - + logger.info("Security failure reset completed successfully", metadata: [ "deleted_files": .stringConvertible(dekFiles.count) ]) @@ -207,6 +231,29 @@ final class HardwareEncryptionScheme: EncryptionScheme { ]) } } + + /// Deletes every EC hardware key this app owns from the keychain. + /// Returns the number of items deleted (or 0 on errSecItemNotFound). + @discardableResult + private func deleteAllHardwareKeys() -> Int { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom + ] + + let status = SecItemDelete(query as CFDictionary) + switch status { + case errSecSuccess: + return 1 // SecItemDelete does not report a count; report at least one + case errSecItemNotFound: + return 0 + default: + logger.error("SecItemDelete failed during security reset", metadata: [ + "status": .stringConvertible(status) + ]) + return 0 + } + } func activatePoisonPill(oldPin: HashedPin?) { if let oldPin = oldPin { @@ -233,94 +280,75 @@ private extension HardwareEncryptionScheme { "file": .string(dekFile.lastPathComponent) ]) } - + + // 1. Remove the Secure Enclave wrap to recover the on-disk payload. let encryptedDek = try Data(contentsOf: dekFile) logger.logDataOperation("decrypt_dek", dataSize: encryptedDek.count) - - return try decryptWithHardwareKey(encrypted: encryptedDek, keyAlias: Self.keyAlias) + let payload = try decryptWithHardwareKey(encrypted: encryptedDek, keyAlias: self.keyAlias) + + // 2. Derive the PIN-wrap key. This is the cryptographic dependency that + // makes the PIN actually required to recover the DEK (C1). + let pinKey = try await pinWrapKey(plainPin: plainPin, hashedPin: hashedPin) + + // 3a. Legacy migration: a payload that is exactly a raw DEK predates the + // PIN-wrap layer (the DEK was PBKDF2(PIN‖…) and only SE-wrapped). + // Preserve that exact DEK value — existing content depends on it — + // and re-wrap it under the PIN key, one shot. + if PinDEKWrapper.isLegacyRawDEK(payload) { + logger.info("Migrating legacy SE-only-wrapped DEK to PIN-wrapped form") + try storeWrappedDEK(dek: payload, pinKey: pinKey, hashedPin: hashedPin) + return payload + } + + // 3b. Normal path: unwrap the PIN-wrapped payload. A wrong PIN fails the + // AES-GCM auth tag and surfaces as CryptoError.wrongPin. + return try PinDEKWrapper.unwrap(payload: payload, pinKey: pinKey) } - + func createWrappedKey(plainPin: String, hashedPin: HashedPin) async throws { try await logger.logAsyncOperation("create_wrapped_key") { - // Create the dSalt (device salt) - var dSalt = Data(count: Self.dSaltSize) - let result = dSalt.withUnsafeMutableBytes { bytes in - SecRandomCopyBytes(kSecRandomDefault, Self.dSaltSize, bytes.bindMemory(to: UInt8.self).baseAddress!) + // The DEK is now a fresh random key, independent of the PIN. The PIN's + // role moves entirely into the wrap layer (see pinWrapKey), so an + // attacker who SE-unwraps the file still cannot recover the DEK + // without the user typing the PIN. + var dekBytes = Data(count: Self.defaultKeySize) + let result = dekBytes.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, Self.defaultKeySize, bytes.bindMemory(to: UInt8.self).baseAddress!) } - guard result == errSecSuccess else { - logger.error("Failed to generate random dSalt", metadata: [ + logger.error("Failed to generate random DEK", metadata: [ "sec_result": .stringConvertible(result) ]) throw CryptoError.randomGenerationFailed } - - logger.debug("Generated dSalt", metadata: [ - "size_bytes": .stringConvertible(Self.dSaltSize) - ]) - - // Derive the key using PBKDF2 - let encodedDSalt = dSalt.base64EncodedString() - let deviceId = await deviceInfo.getDeviceIdentifier() - let encodedDeviceId = deviceId.base64EncodedString() - - let dekInput = plainPin.data(using: .utf8)! + - encodedDSalt.data(using: .utf8)! + - encodedDeviceId.data(using: .utf8)! - - logger.debug("Deriving DEK using PBKDF2", metadata: [ - "iterations": .stringConvertible(Self.defaultIterations), - "key_size": .stringConvertible(Self.defaultKeySize) - ]) - - guard let salt = Data(base64URLString: hashedPin.salt) else { - fatalError("Failed to convert hashed pin to Data") - } - let dekBytes = try derivePBKDF2Key(input: dekInput, salt: salt) - - logger.logDataOperation("derived_dek", dataSize: dekBytes.count) - - // Encrypt and store the DEK using hardware-backed key - let encryptedDek = try encryptWithHardwareKey(plain: dekBytes, keyAlias: Self.keyAlias) - let dekFile = getDekFile(hashedPin: hashedPin) - try encryptedDek.write(to: dekFile) - - logger.info("Encrypted and stored DEK", metadata: [ - "file": .string(dekFile.lastPathComponent), - "encrypted_size": .stringConvertible(encryptedDek.count) - ]) + + let pinKey = try await pinWrapKey(plainPin: plainPin, hashedPin: hashedPin) + try storeWrappedDEK(dek: dekBytes, pinKey: pinKey, hashedPin: hashedPin) } } - - func derivePBKDF2Key(input: Data, salt: Data) throws -> Data { - var derivedKey = Data(count: Self.defaultKeySize) - let result = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in - input.withUnsafeBytes { inputBytes in - salt.withUnsafeBytes { saltBytes in - CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - inputBytes.bindMemory(to: Int8.self).baseAddress!, - input.count, - saltBytes.bindMemory(to: UInt8.self).baseAddress!, - salt.count, - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), - Self.defaultIterations, - derivedKeyBytes.bindMemory(to: UInt8.self).baseAddress!, - Self.defaultKeySize - ) - } - } - } - - guard result == kCCSuccess else { - logger.error("PBKDF2 key derivation failed", metadata: [ - "cc_result": .stringConvertible(result), - "expected": .stringConvertible(kCCSuccess) - ]) + + /// Derives the PIN-wrap key, binding the PIN to the per-credential salt and + /// the device identifier. + func pinWrapKey(plainPin: String, hashedPin: HashedPin) async throws -> SymmetricKey { + guard let salt = Data(base64URLString: hashedPin.salt) else { throw CryptoError.keyDerivationFailed } - - return derivedKey + let deviceId = await deviceInfo.getDeviceIdentifier() + return try PinDEKWrapper.derivePinKey(plainPin: plainPin, salt: salt, deviceId: deviceId) + } + + /// AES-GCM-wraps the DEK under the PIN key, then Secure-Enclave-wraps that + /// payload and writes it to disk with complete file protection. + func storeWrappedDEK(dek: Data, pinKey: SymmetricKey, hashedPin: HashedPin) throws { + let pinWrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + let encryptedDek = try encryptWithHardwareKey(plain: pinWrapped, keyAlias: self.keyAlias) + let dekFile = getDekFile(hashedPin: hashedPin) + try encryptedDek.write(to: dekFile, options: [.completeFileProtection, .atomic]) + + logger.info("Encrypted and stored PIN-wrapped DEK", metadata: [ + "file": .string(dekFile.lastPathComponent), + "encrypted_size": .stringConvertible(encryptedDek.count) + ]) } // MARK: - Hardware Key Management @@ -449,11 +477,16 @@ private extension HardwareEncryptionScheme { var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let key = item else { + // `as?` to a CoreFoundation type from CFTypeRef never performs a real + // runtime check, so verify the type explicitly via its CFTypeID. After + // that check the downcast is guaranteed, so it needs no force cast. + guard status == errSecSuccess, + let item, + CFGetTypeID(item) == SecKeyGetTypeID() else { throw CryptoError.keyNotFound } - - return key as! SecKey + + return unsafeDowncast(item, to: SecKey.self) } // MARK: - Hardware Encryption/Decryption @@ -521,26 +554,29 @@ private extension HardwareEncryptionScheme { return decryptedData as Data } - - // MARK: - File Management - +} + +// MARK: - File Management +extension HardwareEncryptionScheme { + func getKeyDirectory() -> URL { let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] var keyDir = appSupportPath.appendingPathComponent(Self.dekDirectory) - - // Create directory and exclude from backup + + // Create directory, set file protection, and exclude from backup do { try FileManager.default.createDirectory(at: keyDir, withIntermediateDirectories: true, attributes: nil) var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true try keyDir.setResourceValues(resourceValues) + try FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: keyDir.path) } catch { Logger.storage.error("Failed to setup key directory: \(error)") } - + return keyDir } - + func getDekFile(hashedPin: HashedPin) -> URL { // Hash the pin hash to create a safe filename (similar to Android implementation) guard let pinData = Data(base64URLString: hashedPin.hash) else { @@ -551,13 +587,20 @@ private extension HardwareEncryptionScheme { .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "=", with: "") - + return getKeyDirectory().appendingPathComponent("\(Self.dekFilenamePrefix)_\(hashString)") } + + /// Test-only hook: Secure-Enclave-unwrap an on-disk DEK file payload so tests + /// can assert it is stored PIN-wrapped (not as a raw DEK). Uses the scheme's + /// own KEK alias. + func decryptWithHardwareKeyForTesting(encrypted: Data) throws -> Data { + try decryptWithHardwareKey(encrypted: encrypted, keyAlias: self.keyAlias) + } } // MARK: - Custom Errors -enum CryptoError: Error, LocalizedError { +enum CryptoError: Error, LocalizedError, Equatable { case keyNotDerived case keyNotFound case keyGenerationFailed(String) @@ -566,7 +609,10 @@ enum CryptoError: Error, LocalizedError { case keyDerivationFailed case randomGenerationFailed case invalidCiphertext - + /// The supplied PIN could not unwrap the DEK (AES-GCM authentication failed + /// or the wrapped payload was malformed). Surfaced as a clean "wrong PIN". + case wrongPin + var errorDescription: String? { switch self { case .keyNotDerived: @@ -585,6 +631,8 @@ enum CryptoError: Error, LocalizedError { return "Random number generation failed" case .invalidCiphertext: return "Invalid ciphertext format" + case .wrongPin: + return "Incorrect PIN" } } } diff --git a/SnapSafe/Data/Encryption/PassThroughEncryptionScheme.swift b/SnapSafe/Data/Encryption/PassThroughEncryptionScheme.swift index 5b38b6e..81a1547 100644 --- a/SnapSafe/Data/Encryption/PassThroughEncryptionScheme.swift +++ b/SnapSafe/Data/Encryption/PassThroughEncryptionScheme.swift @@ -18,6 +18,7 @@ final class PassThroughEncryptionScheme: EncryptionScheme, @unchecked Sendable { try plain.write(to: targetFile) } + // periphery:ignore func encrypt(plain: Data, keyBytes: Data) async throws -> Data { return plain } @@ -49,7 +50,7 @@ final class PassThroughEncryptionScheme: EncryptionScheme, @unchecked Sendable { return Data(plainPin.utf8) } - func evictKey() { + func evictKey() async { cachedKey = nil } diff --git a/SnapSafe/Data/Encryption/PinDEKWrapper.swift b/SnapSafe/Data/Encryption/PinDEKWrapper.swift new file mode 100644 index 0000000..29c2324 --- /dev/null +++ b/SnapSafe/Data/Encryption/PinDEKWrapper.swift @@ -0,0 +1,134 @@ +// +// PinDEKWrapper.swift +// SnapSafe +// +// Created by Claude on 2026-05-31. +// +// C1 fix — PIN-derived AES wrap for the DEK. +// +// Historically the DEK was derived directly from the PIN (PBKDF2) and then +// wrapped only by the Secure Enclave key. On the load path the SE alone +// reproduced the DEK, so the PIN was a Swift-level gate, not a cryptographic +// dependency — anything reaching `SecKeyCreateDecryptedData` on an unlocked +// device recovered the DEK without the PIN. +// +// This type adds a PIN-derived AES-GCM layer *under* the SE wrap: +// +// DEK = random(32) // independent of the PIN +// pinKey = PBKDF2(prefix ‖ PIN ‖ deviceID, salt) +// payload = AES-GCM(DEK, key: pinKey) // nonce ‖ ciphertext ‖ tag +// stored = SE_wrap(payload) +// +// Recovering the DEK now requires the user to actually type the PIN; the +// attacker on an unlocked device gets only the PIN-wrapped blob. The PIN +// remains uncoercible (unlike biometrics / device passcode), which is the +// point — see design/2026-05-31-c1-pin-binding-analysis. +// +// This unit deliberately depends only on CryptoKit + CommonCrypto (no +// keychain / Secure Enclave) so it is fully unit-testable on the simulator. + +import CommonCrypto +import CryptoKit +import Foundation + +enum PinDEKWrapper { + + /// Domain-separation prefix so the PIN-wrap key derivation can never collide + /// with any other PBKDF2 use of the same PIN/salt. + private static let domainPrefix = "snapsafe-pinwrap-v1:" + + /// PBKDF2 iterations. Matches the scheme's existing cost (OWASP 2024 ≥ 600k). + static let iterations: UInt32 = 600_000 + + /// 256-bit derived key / 256-bit DEK. + static let keySize = 32 + + /// AES-GCM framing sizes. + static let nonceSize = 12 + static let tagSize = 16 + + /// A raw (legacy) DEK is exactly `keySize` bytes; a PIN-wrapped payload is + /// `nonceSize + keySize + tagSize` bytes. The two never collide, so length + /// is an unambiguous discriminator for one-shot migration. + static let wrappedSize = nonceSize + keySize + tagSize + + // MARK: - Key derivation + + /// Derives the PIN-wrap key from the plain PIN, bound to the device. + /// - Parameters: + /// - plainPin: the user's PIN (never persisted). + /// - salt: per-credential salt (the Argon2 `hashedPin.salt`). + /// - deviceId: stable device identifier bytes. + static func derivePinKey(plainPin: String, salt: Data, deviceId: Data) throws -> SymmetricKey { + var input = Data(domainPrefix.utf8) + input.append(Data(plainPin.utf8)) + input.append(deviceId) + + var derived = Data(count: keySize) + let status = derived.withUnsafeMutableBytes { outBytes in + input.withUnsafeBytes { inBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + inBytes.bindMemory(to: Int8.self).baseAddress!, + input.count, + saltBytes.bindMemory(to: UInt8.self).baseAddress!, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), + iterations, + outBytes.bindMemory(to: UInt8.self).baseAddress!, + keySize + ) + } + } + } + + guard status == kCCSuccess else { + throw CryptoError.keyDerivationFailed + } + return SymmetricKey(data: derived) + } + + // MARK: - Wrap / unwrap + + /// Wraps a DEK under the PIN-derived key. Output is `nonce ‖ ciphertext ‖ tag`. + static func wrap(dek: Data, pinKey: SymmetricKey) throws -> Data { + let sealed = try AES.GCM.seal(dek, using: pinKey) + var out = Data() + out.append(sealed.nonce.withUnsafeBytes { Data($0) }) + out.append(sealed.ciphertext) + out.append(sealed.tag) + return out + } + + /// Unwraps a PIN-wrapped payload. Throws `CryptoError.wrongPin` if the PIN + /// key does not match (AES-GCM authentication failure) or the payload is + /// malformed. + static func unwrap(payload: Data, pinKey: SymmetricKey) throws -> Data { + guard payload.count == wrappedSize else { + throw CryptoError.wrongPin + } + let nonceData = payload.prefix(nonceSize) + let ciphertext = payload.dropFirst(nonceSize).dropLast(tagSize) + let tag = payload.suffix(tagSize) + + do { + let box = try AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: nonceData), + ciphertext: ciphertext, + tag: tag + ) + return try AES.GCM.open(box, using: pinKey) + } catch { + // Any failure here (auth-tag mismatch, bad framing) means the PIN + // key was wrong. Collapse to a single, non-leaky error. + throw CryptoError.wrongPin + } + } + + /// True if the on-disk payload is a legacy, SE-only-wrapped raw DEK (exactly + /// `keySize` bytes), as opposed to a PIN-wrapped payload (`wrappedSize`). + static func isLegacyRawDEK(_ data: Data) -> Bool { + data.count == keySize + } +} diff --git a/SnapSafe/Data/Encryption/VideoEncryptionService.swift b/SnapSafe/Data/Encryption/VideoEncryptionService.swift new file mode 100644 index 0000000..c406d46 --- /dev/null +++ b/SnapSafe/Data/Encryption/VideoEncryptionService.swift @@ -0,0 +1,379 @@ +// +// VideoEncryptionService.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import CryptoKit +import Combine +import Logging + +/// Service for encrypting and decrypting videos using the SECV format. +@MainActor +protocol VideoEncryptionServiceProtocol: Sendable { + /// Encrypt a video file using SECV format. + /// - Parameters: + /// - inputURL: URL of the unencrypted video file + /// - outputURL: URL where the encrypted file should be written + /// - encryptionKey: Key to use for encryption + /// - Returns: Progress publisher and completion promise + // periphery:ignore + func encryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) + + /// Decrypt a video file from SECV format. + /// - Parameters: + /// - inputURL: URL of the encrypted video file + /// - outputURL: URL where the decrypted file should be written + /// - encryptionKey: Key to use for decryption + /// - Returns: Progress publisher and completion promise + // periphery:ignore + func decryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) + + /// Decrypt a video file from SECV format, awaiting completion before returning. + /// Use this instead of decryptVideo when the caller needs the file ready before proceeding. + func decryptVideoForSharing(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws + + /// Encrypt a video file using SECV format, awaiting completion before returning. + /// Use this when the caller needs the encrypted file ready before proceeding + /// (e.g. re-encrypting a decoy video with the poison-pill key). + func encryptVideoForDecoy(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws + + /// Validate that a file has proper SECV format. + /// - Parameter fileURL: URL of the file to validate + /// - Returns: True if the file has valid SECV format + // periphery:ignore + func validateSECVFile(fileURL: URL) -> Bool +} + +@MainActor +final class VideoEncryptionService: VideoEncryptionServiceProtocol { + + private let logger = Logger.video + + /// Encrypt a video file using SECV format. + func encryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) { + let progressSubject = PassthroughSubject() + + let completionHandler: (Result) -> Void = { result in + switch result { + case .success(let url): + self.logger.info("Video encryption completed successfully", metadata: [ + "file": .string(url.lastPathComponent) + ]) + case .failure(let error): + self.logger.error("Video encryption failed", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + + // Start encryption in background + Task(priority: .userInitiated) { + do { + try await encryptVideoFile(inputURL: inputURL, outputURL: outputURL, encryptionKey: encryptionKey, progressHandler: { progress in + progressSubject.send(progress) + }) + // Guarantee subscribers see 1.0 on success; chunk-based + // progress is not a reliable completion signal on its own. + progressSubject.send(1.0) + completionHandler(.success(outputURL)) + } catch { + completionHandler(.failure(error)) + } + // Always finish the stream. Subscribers distinguish failure as + // "finished without reaching 1.0" — without this, a failed + // encryption left observers (e.g. the saving HUD) waiting forever. + progressSubject.send(completion: .finished) + } + + return (progressSubject.eraseToAnyPublisher(), completionHandler) + } + + /// Decrypt a video file from SECV format. + func decryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) { + let progressSubject = PassthroughSubject() + + let completionHandler: (Result) -> Void = { result in + switch result { + case .success(let url): + self.logger.info("Video decryption completed successfully", metadata: [ + "file": .string(url.lastPathComponent) + ]) + case .failure(let error): + self.logger.error("Video decryption failed", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + + // Start decryption in background + Task(priority: .userInitiated) { + do { + try await decryptVideoFile(inputURL: inputURL, outputURL: outputURL, encryptionKey: encryptionKey, progressHandler: { progress in + progressSubject.send(progress) + }) + completionHandler(.success(outputURL)) + } catch { + completionHandler(.failure(error)) + } + } + + return (progressSubject.eraseToAnyPublisher(), completionHandler) + } + + func decryptVideoForSharing(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws { + try await decryptVideoFile(inputURL: inputURL, outputURL: outputURL, encryptionKey: encryptionKey, progressHandler: { _ in }) + } + + func encryptVideoForDecoy(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws { + try await encryptVideoFile(inputURL: inputURL, outputURL: outputURL, encryptionKey: encryptionKey, progressHandler: { _ in }) + } + + /// Validate that a file has proper SECV format. + func validateSECVFile(fileURL: URL) -> Bool { + do { + let fileSize = try getFileSize(fileURL: fileURL) + let trailerData = try readTrailerData(fileURL: fileURL, fileSize: fileSize) + let trailer = try SECVFileFormat.SecvTrailer.from(data: trailerData) + + // Verify the file size matches the expected format + let expectedSize = SECVFileFormat.calculateTotalFileSize( + originalSize: trailer.originalSize, + totalChunks: trailer.totalChunks + ) + + return expectedSize == fileSize + } catch { + logger.warning("SECV validation failed", metadata: [ + "file": .string(fileURL.lastPathComponent), + "error": .string(error.localizedDescription) + ]) + return false + } + } + + // MARK: - Private Implementation + + /// Main encryption method that processes the video file. + private func encryptVideoFile(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey, progressHandler: @escaping (Double) -> Void) async throws { + logger.info("Starting video encryption", metadata: [ + "input": .string(inputURL.lastPathComponent), + "output": .string(outputURL.lastPathComponent) + ]) + + // Get file size and calculate chunks + let fileSize = try getFileSize(fileURL: inputURL) + let chunkSize = SECVFileFormat.DEFAULT_CHUNK_SIZE + let totalChunks = (fileSize + UInt64(chunkSize) - 1) / UInt64(chunkSize) + + logger.info("Video encryption parameters", metadata: [ + "fileSize": .stringConvertible(fileSize), + "chunkSize": .stringConvertible(chunkSize), + "totalChunks": .stringConvertible(totalChunks) + ]) + + // Open input and output files + let inputFile = try FileHandle(forReadingFrom: inputURL) + defer { inputFile.closeFile() } + + let outputFile = try FileHandle(forWritingTo: outputURL) + defer { outputFile.closeFile() } + + // Process each chunk + var currentOffset: UInt64 = 0 + var chunksProcessed: UInt64 = 0 + + for _ in 0.. Void) async throws { + logger.info("Starting video decryption", metadata: [ + "input": .string(inputURL.lastPathComponent), + "output": .string(outputURL.lastPathComponent) + ]) + + // Read and validate trailer + let fileSize = try getFileSize(fileURL: inputURL) + let trailerData = try readTrailerData(fileURL: inputURL, fileSize: fileSize) + let trailer = try SECVFileFormat.SecvTrailer.from(data: trailerData) + + logger.info("Video decryption parameters", metadata: [ + "originalSize": .stringConvertible(trailer.originalSize), + "chunkSize": .stringConvertible(trailer.chunkSize), + "totalChunks": .stringConvertible(trailer.totalChunks) + ]) + + // Open input and output files + let inputFile = try FileHandle(forReadingFrom: inputURL) + defer { inputFile.closeFile() } + + let outputFile = try FileHandle(forWritingTo: outputURL) + defer { outputFile.closeFile() } + + // Process each chunk + var chunksProcessed: UInt64 = 0 + + for chunkIndex in 0.. Data { + var ivData = Data(count: SECVFileFormat.IV_SIZE) + let result = ivData.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, SECVFileFormat.IV_SIZE, $0.baseAddress!) + } + guard result == errSecSuccess else { + fatalError("Failed to generate random IV") + } + return ivData + } + + /// Encrypt a single chunk using AES-GCM. + private func encryptChunk(plaintext: Data, key: SymmetricKey, iv: Data) throws -> (ciphertext: Data, tag: Data) { + let sealedBox = try AES.GCM.seal(plaintext, using: key, nonce: AES.GCM.Nonce(data: iv)) + return (sealedBox.ciphertext, sealedBox.tag) + } + + /// Decrypt a single chunk using AES-GCM. + private func decryptChunk(ciphertext: Data, key: SymmetricKey, iv: Data, tag: Data) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: ciphertext, tag: tag) + return try AES.GCM.open(sealedBox, using: key) + } + + /// Write the chunk index table to the output file. + private func writeChunkIndexTable(outputFile: FileHandle, totalChunks: UInt64, chunkSize: UInt32) throws { + var currentOffset: UInt64 = 0 + var indexTableData = Data() + + for _ in 0.. UInt64 { + let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + guard let fileSize = attributes[.size] as? UInt64 else { + throw SECVError.fileIOError + } + return fileSize + } + + /// Read trailer data from the end of the file. + private func readTrailerData(fileURL: URL, fileSize: UInt64) throws -> Data { + let trailerPosition = SECVFileFormat.calculateTrailerPosition(fileLength: fileSize) + let inputFile = try FileHandle(forReadingFrom: fileURL) + defer { inputFile.closeFile() } + + try inputFile.seek(toOffset: trailerPosition) + let trailerData = try inputFile.read(upToCount: SECVFileFormat.TRAILER_SIZE) + + guard let trailerData = trailerData, trailerData.count == SECVFileFormat.TRAILER_SIZE else { + throw SECVError.invalidTrailerSize + } + + return trailerData + } +} + diff --git a/SnapSafe/Data/Location/LocationRepository.swift b/SnapSafe/Data/Location/LocationRepository.swift index 348f5ef..62a03b3 100644 --- a/SnapSafe/Data/Location/LocationRepository.swift +++ b/SnapSafe/Data/Location/LocationRepository.swift @@ -20,6 +20,11 @@ class LocationRepository: NSObject, ObservableObject { @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined @Published var lastLocation: CLLocation? + // The user's iOS-level Precise/Approximate choice. Only meaningful while + // location access is authorized; the system reports `.fullAccuracy` by + // default otherwise. + @Published var accuracyAuthorization: CLAccuracyAuthorization = .fullAccuracy + override init() { super.init() locationManager.delegate = self @@ -27,6 +32,7 @@ class LocationRepository: NSObject, ObservableObject { // Get the current authorization status authorizationStatus = locationManager.authorizationStatus + accuracyAuthorization = locationManager.accuracyAuthorization // Start tracking if we already have permission startUpdatingLocationIfAuthorized() @@ -65,6 +71,29 @@ class LocationRepository: NSObject, ObservableObject { return "Unknown" } } + + // Whether location access is currently granted (when-in-use or always). + var isAuthorized: Bool { + authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways + } + + // User-friendly string for the user's iOS-level Precise/Approximate choice. + func getAccuracyAuthorizationString() -> String { + Self.accuracyDisplayString(for: accuracyAuthorization) + } + + // Maps the iOS accuracy authorization to a display string. Pure and static + // so the mapping can be unit-tested without a live CLLocationManager. + static func accuracyDisplayString(for accuracy: CLAccuracyAuthorization) -> String { + switch accuracy { + case .fullAccuracy: + return "Precise" + case .reducedAccuracy: + return "Approximate" + @unknown default: + return "Unknown" + } + } } // MARK: - CLLocationManagerDelegate @@ -73,6 +102,7 @@ extension LocationRepository: CLLocationManagerDelegate { // Called when the authorization status changes func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus + accuracyAuthorization = manager.accuracyAuthorization // Automatically start or stop location updates based on authorization status if authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways { diff --git a/SnapSafe/Data/Models/DetectedFace.swift b/SnapSafe/Data/Models/DetectedFace.swift index 17e5d5c..14ad55b 100644 --- a/SnapSafe/Data/Models/DetectedFace.swift +++ b/SnapSafe/Data/Models/DetectedFace.swift @@ -27,10 +27,6 @@ public struct _DetectedFace: Identifiable, Hashable { self.rightEye = rightEye } - public init(rect: CGRect, isSelected: Bool = false) { - self.init(bounds: rect, isSelected: isSelected, isUserCreated: false) - } - /// Aspect-fit scale and offset for drawing an image of `original` inside a `display` rect. /// Returns `(scale, offset)` where `offset` is the top-left inset inside the display area. public static func aspectFitScaleAndOffset(original: CGSize, display: CGSize) -> (CGFloat, CGPoint) { @@ -44,12 +40,6 @@ public struct _DetectedFace: Identifiable, Hashable { return (scale, offset) } - /// Convert a display-space point (inside the aspect-fit image frame) to image-space. - public static func imagePoint(fromDisplay p: CGPoint, originalSize: CGSize, displaySize: CGSize) -> CGPoint { - let (scale, offset) = aspectFitScaleAndOffset(original: originalSize, display: displaySize) - return CGPoint(x: (p.x - offset.x) / scale, y: (p.y - offset.y) / scale) - } - /// Convert a display-space delta (drag translation) to image-space delta. public static func imageDelta(fromDisplay delta: CGSize, originalSize: CGSize, displaySize: CGSize) -> CGSize { let (scale, _) = aspectFitScaleAndOffset(original: originalSize, display: displaySize) diff --git a/SnapSafe/Data/Models/MediaItem.swift b/SnapSafe/Data/Models/MediaItem.swift new file mode 100644 index 0000000..0949555 --- /dev/null +++ b/SnapSafe/Data/Models/MediaItem.swift @@ -0,0 +1,134 @@ +// +// MediaItem.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import SwiftUI +import AVFoundation +import CryptoKit + +// periphery:ignore all +/// Protocol for media items (photos and videos) in the gallery. +protocol MediaItem: Identifiable, Hashable { + // periphery:ignore + var id: UUID { get } + var mediaName: String { get } + // periphery:ignore + var mediaFile: URL { get } + var mediaType: MediaType { get } + func dateTaken() -> Date? + // periphery:ignore + var thumbnail: UIImage? { get } + // periphery:ignore + var isEncrypted: Bool { get } +} + +/// Media type enum. +enum MediaType: String, CaseIterable { + case photo + case video +} + +// periphery:ignore all +/// Extension to make PhotoDef conform to MediaItem. +extension PhotoDef: MediaItem { + var mediaName: String { return photoName } + // periphery:ignore + var mediaFile: URL { return photoFile } + var mediaType: MediaType { return .photo } + // periphery:ignore + var isEncrypted: Bool { return true } // Photos are always encrypted in SnapSafe + + // Thumbnail generation for photos + // periphery:ignore + var thumbnail: UIImage? { + // Use existing thumbnail logic from PhotoDef + // This would typically load from thumbnail cache + return nil // Placeholder - actual implementation would load thumbnail + } +} + +// periphery:ignore all +/// Extension to make VideoDef conform to MediaItem. +extension VideoDef: MediaItem { + var mediaName: String { return videoName } + // periphery:ignore + var mediaFile: URL { return videoFile } + var mediaType: MediaType { return .video } + // isEncrypted is already defined in VideoDef.swift + + // Thumbnail generation for videos + // periphery:ignore + var thumbnail: UIImage? { + return generateVideoThumbnail() + } + + // periphery:ignore + private func generateVideoThumbnail() -> UIImage? { + guard FileManager.default.fileExists(atPath: videoFile.path) else { + return nil + } + + let asset: AVAsset + if isEncrypted { + // For encrypted videos, we need the encryption key + // In a real app, this would come from the secure storage + // For now, return a placeholder + return UIImage(systemName: "video.fill")?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal) + } else { + // For unencrypted videos, generate thumbnail normally + asset = AVURLAsset(url: videoFile) + } + + let assetGenerator = AVAssetImageGenerator(asset: asset) + assetGenerator.appliesPreferredTrackTransform = true + + do { + let time = CMTime(seconds: 1, preferredTimescale: 60) // Get thumbnail at 1 second + let cgImage = try assetGenerator.copyCGImage(at: time, actualTime: nil) + return UIImage(cgImage: cgImage) + } catch { + print("Failed to generate video thumbnail: \(error)") + return UIImage(systemName: "video.slash")?.withTintColor(.systemRed, renderingMode: .alwaysOriginal) + } + } +} + +/// Gallery media item that can represent either a photo or video. +struct GalleryMediaItem: Identifiable, Hashable { + let id = UUID() + let mediaItem: any MediaItem + let encryptionKey: SymmetricKey? // Only needed for encrypted videos + + // Convenience properties to access underlying media item + var mediaName: String { mediaItem.mediaName } + // periphery:ignore + var mediaFile: URL { mediaItem.mediaFile } + var mediaType: MediaType { mediaItem.mediaType } + func dateTaken() -> Date? { mediaItem.dateTaken() } + // periphery:ignore + var thumbnail: UIImage? { mediaItem.thumbnail } + // periphery:ignore + var isEncrypted: Bool { mediaItem.isEncrypted } + + // For type-safe access to specific media types + var photoDef: PhotoDef? { + return mediaItem as? PhotoDef + } + + var videoDef: VideoDef? { + return mediaItem as? VideoDef + } + + // Hashable conformance + static func == (lhs: GalleryMediaItem, rhs: GalleryMediaItem) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} \ No newline at end of file diff --git a/SnapSafe/Data/Models/PhotoDef.swift b/SnapSafe/Data/Models/PhotoDef.swift index 4167094..e8ae912 100644 --- a/SnapSafe/Data/Models/PhotoDef.swift +++ b/SnapSafe/Data/Models/PhotoDef.swift @@ -9,14 +9,12 @@ import Foundation import UIKit struct PhotoDef: Hashable, Identifiable { - public let id = UUID() + let id = UUID() let photoName: String - let photoFormat: String let photoFile: URL - - init(photoName: String, photoFormat: String, photoFile: URL) { + + init(photoName: String, photoFormat _: String, photoFile: URL) { self.photoName = photoName - self.photoFormat = photoFormat self.photoFile = photoFile } diff --git a/SnapSafe/Data/Models/PhotoMetaData.swift b/SnapSafe/Data/Models/PhotoMetaData.swift index fbd44c1..38ef1b1 100644 --- a/SnapSafe/Data/Models/PhotoMetaData.swift +++ b/SnapSafe/Data/Models/PhotoMetaData.swift @@ -9,6 +9,11 @@ import Foundation import CoreLocation import UIKit +/// Represents the current capture mode of the camera. +enum CaptureMode { + case photo + case video +} struct CapturedImage { let sensorBitmap: UIImage diff --git a/SnapSafe/Data/Models/SECVFileFormat.swift b/SnapSafe/Data/Models/SECVFileFormat.swift new file mode 100644 index 0000000..ac245e4 --- /dev/null +++ b/SnapSafe/Data/Models/SECVFileFormat.swift @@ -0,0 +1,213 @@ +// +// SECVFileFormat.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation + +/// SECV (Secure Encrypted Camera Video) file format constants and utilities. +/// +/// File Format: +/// [Encrypted Chunks] +/// - Per chunk: [12-byte IV][ciphertext][16-byte auth tag] +/// +/// [Chunk Index Table: 12 bytes per chunk] +/// - Chunk offset: uint64 (8 bytes) +/// - Encrypted size: uint32 (4 bytes) +/// +/// [Trailer: 64 bytes] - Located at end of file +/// - Magic: "SECV" (4 bytes) +/// - Version: uint16 (2 bytes) +/// - Chunk size: uint32 (4 bytes) +/// - Total chunks: uint64 (8 bytes) +/// - Original size: uint64 (8 bytes) +/// - Reserved: padding to 64 bytes (38 bytes) +/// +/// The trailer format (chunks first, metadata at end) eliminates the need +/// to rewrite the entire file when encryption completes, preventing memory +/// spikes from loading large videos into RAM. +enum SECVFileFormat { + static let MAGIC = "SECV" + static let VERSION: UInt16 = 1 + static let TRAILER_SIZE = 64 + static let CHUNK_INDEX_ENTRY_SIZE = 12 + static let IV_SIZE = 12 + static let AUTH_TAG_SIZE = 16 + static let DEFAULT_CHUNK_SIZE = 1_048_576 // 1MB + + static let FILE_EXTENSION = "secv" + + // Trailer field offsets + private static let OFFSET_MAGIC = 0 + private static let OFFSET_VERSION = 4 + private static let OFFSET_CHUNK_SIZE = 6 + private static let OFFSET_TOTAL_CHUNKS = 10 + private static let OFFSET_ORIGINAL_SIZE = 18 + + /// Represents the trailer of a SECV file (metadata at end of file). + struct SecvTrailer: Equatable { + let version: UInt16 + let chunkSize: UInt32 + let totalChunks: UInt64 + let originalSize: UInt64 + + init(version: UInt16, chunkSize: UInt32, totalChunks: UInt64, originalSize: UInt64) { + self.version = version + self.chunkSize = chunkSize + self.totalChunks = totalChunks + self.originalSize = originalSize + } + + /// Convert trailer to byte array for writing to file. + func toData() -> Data { + var data = Data(count: SECVFileFormat.TRAILER_SIZE) + + // Magic + data.replaceSubrange(OFFSET_MAGIC.. SecvTrailer { + guard data.count >= TRAILER_SIZE else { + throw SECVError.invalidTrailerSize + } + + // Verify magic + let magicData = data.subdata(in: OFFSET_MAGIC.. Data { + var data = Data(count: CHUNK_INDEX_ENTRY_SIZE) + + // Offset (little-endian) + withUnsafeBytes(of: offset.littleEndian) { data.replaceSubrange(0..<8, with: $0) } + + // Encrypted size (little-endian) + withUnsafeBytes(of: encryptedSize.littleEndian) { data.replaceSubrange(8..<12, with: $0) } + + return data + } + + /// Parse chunk index entry from byte array. + static func from(data: Data, offset: Int = 0) throws -> ChunkIndexEntry { + guard data.count >= offset + CHUNK_INDEX_ENTRY_SIZE else { + throw SECVError.invalidChunkIndexEntry + } + + let subdata = data.subdata(in: offset.. Int { + return IV_SIZE + plaintextSize + AUTH_TAG_SIZE + } + + /// Calculate the position of the trailer in the file (last 64 bytes). + /// For trailer format, trailer is at: fileLength - TRAILER_SIZE + static func calculateTrailerPosition(fileLength: UInt64) -> UInt64 { + return fileLength - UInt64(TRAILER_SIZE) + } + + /// Calculate the position of the index table in the file. + /// For trailer format, index is at: fileLength - TRAILER_SIZE - (totalChunks * CHUNK_INDEX_ENTRY_SIZE) + static func calculateIndexTablePosition(fileLength: UInt64, totalChunks: UInt64) -> UInt64 { + return fileLength - UInt64(TRAILER_SIZE) - (totalChunks * UInt64(CHUNK_INDEX_ENTRY_SIZE)) + } + + /// Calculate the plaintext offset for a given chunk index. + static func calculatePlaintextOffset(chunkIndex: UInt64, chunkSize: UInt32) -> UInt64 { + return chunkIndex * UInt64(chunkSize) + } + + /// Calculate the total file size for a given original size and chunk count. + static func calculateTotalFileSize(originalSize _: UInt64, totalChunks: UInt64) -> UInt64 { + let encryptedDataSize = totalChunks * UInt64(DEFAULT_CHUNK_SIZE + IV_SIZE + AUTH_TAG_SIZE) + let indexTableSize = totalChunks * UInt64(CHUNK_INDEX_ENTRY_SIZE) + return encryptedDataSize + indexTableSize + UInt64(TRAILER_SIZE) + } +} + +/// SECV-specific errors. +enum SECVError: Error, LocalizedError { + case invalidTrailerSize + case invalidMagic + case invalidChunkIndexEntry + case invalidFileFormat + case encryptionFailed + case decryptionFailed + case fileIOError + case checksumMismatch + + var errorDescription: String? { + switch self { + case .invalidTrailerSize: return "Invalid SECV trailer size" + case .invalidMagic: return "Invalid SECV magic number" + case .invalidChunkIndexEntry: return "Invalid chunk index entry" + case .invalidFileFormat: return "Invalid SECV file format" + case .encryptionFailed: return "Video encryption failed" + case .decryptionFailed: return "Video decryption failed" + case .fileIOError: return "File I/O error" + case .checksumMismatch: return "Checksum mismatch" + } + } +} \ No newline at end of file diff --git a/SnapSafe/Data/Models/VideoDef.swift b/SnapSafe/Data/Models/VideoDef.swift new file mode 100644 index 0000000..ab849f7 --- /dev/null +++ b/SnapSafe/Data/Models/VideoDef.swift @@ -0,0 +1,121 @@ +// +// VideoDef.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import AVFoundation + +struct VideoDef: Hashable, Identifiable { + let id = UUID() + let videoName: String + let videoFormat: String + let videoFile: URL + + init(videoName: String, videoFormat: String, videoFile: URL) { + self.videoName = videoName + self.videoFormat = videoFormat + self.videoFile = videoFile + } + + /// Returns true if this video is encrypted (uses .secv format). + var isEncrypted: Bool { + return videoFormat == SECVFileFormat.FILE_EXTENSION + } + + func dateTaken() -> Date? { + // videoName carries no extension (e.g. "video_yyyyMMdd_HHmmss"), so + // stripping the "video_" prefix leaves the parseable timestamp. + let dateString = videoName.replacingOccurrences(of: "video_", with: "") + + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + + return formatter.date(from: dateString) + } + + /// Get the encryption status of the video file. + // periphery:ignore + func getEncryptionStatus() -> VideoEncryptionStatus { + if isEncrypted { + // Check if file has valid SECV format + do { + let fileSize = try getFileSize() + let trailerData = try readTrailerData(fileSize: fileSize) + let trailer = try SECVFileFormat.SecvTrailer.from(data: trailerData) + + // Verify the file size matches the expected format + let expectedSize = SECVFileFormat.calculateTotalFileSize( + originalSize: trailer.originalSize, + totalChunks: trailer.totalChunks + ) + + if expectedSize == fileSize { + return .encrypted + } else { + return .corrupted + } + } catch { + return .corrupted + } + } else if videoFormat == "mov" || videoFormat == "mp4" { + return .unencrypted + } else { + return .unknown + } + } + + /// Get the file size in bytes. + private func getFileSize() throws -> UInt64 { + let attributes = try FileManager.default.attributesOfItem(atPath: videoFile.path) + guard let fileSize = attributes[.size] as? UInt64 else { + throw SECVError.fileIOError + } + return fileSize + } + + /// Read the trailer data from the end of the file. + private func readTrailerData(fileSize: UInt64) throws -> Data { + let trailerPosition = SECVFileFormat.calculateTrailerPosition(fileLength: fileSize) + let fileHandle = try FileHandle(forReadingFrom: videoFile) + defer { fileHandle.closeFile() } + + try fileHandle.seek(toOffset: UInt64(trailerPosition)) + let trailerData = try fileHandle.read(upToCount: SECVFileFormat.TRAILER_SIZE) + + guard let trailerData = trailerData, trailerData.count == SECVFileFormat.TRAILER_SIZE else { + throw SECVError.invalidTrailerSize + } + + return trailerData + } + + /// Get video duration if available (for unencrypted videos). + // periphery:ignore + func getDuration() async -> TimeInterval? { + guard !isEncrypted else { return nil } + + let asset = AVURLAsset(url: videoFile) + + // Load duration asynchronously to avoid blocking + do { + let duration = try await asset.load(.duration) + return duration.seconds + } catch { + print("Failed to load video duration: \(error)") + return nil + } + } +} + +/// Video encryption status. +// periphery:ignore +enum VideoEncryptionStatus { + case unencrypted // Video is in plaintext format (.mov, .mp4) + case encrypted // Video is properly encrypted (.secv) + case corrupted // Video file is corrupted or has invalid format + case unknown // Unknown video format +} \ No newline at end of file diff --git a/SnapSafe/Data/Models/VideoMetaData.swift b/SnapSafe/Data/Models/VideoMetaData.swift new file mode 100644 index 0000000..5f6ae46 --- /dev/null +++ b/SnapSafe/Data/Models/VideoMetaData.swift @@ -0,0 +1,94 @@ +// +// VideoMetaData.swift +// SnapSafe +// +// Structured metadata for a video, surfaced by SecureImageRepository and +// rendered by VideoInfoView. Mirrors SecureImageRepository.PhotoMetaData plus +// video-specific technical fields. +// + +import Foundation + +enum DateSource { + case embedded // read from the file's kCommonIdentifierCreationDate + case filename // derived from the video_yyyyMMdd_HHmmss filename +} + +struct VideoMetaData { + let resolution: Size + let duration: TimeInterval + let dateTaken: Date + let dateTakenSource: DateSource + let location: GpsCoordinates? + let orientation: TiffOrientation? + let codec: String? + let frameRate: Double? + let bitrate: Int? // bits per second + let fileSize: Int64 +} + +// MARK: - Display strings + +extension VideoMetaData { + + /// Shown in rows when the underlying value is missing/unavailable. + static let unavailable = "—" + + var resolutionString: String { + guard resolution.width > 0, resolution.height > 0 else { return Self.unavailable } + return "\(resolution.width) × \(resolution.height)" + } + + var fileSizeString: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: fileSize) + } + + var dateTakenString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter.string(from: dateTaken) + } + + var isDateFromFilename: Bool { dateTakenSource == .filename } + + var orientationString: String { + guard let orientation else { return Self.unavailable } + switch orientation { + case .up: return "Normal" + case .down: return "Rotated 180°" + case .right: return "Rotated 90° CW" + case .left: return "Rotated 90° CCW" + default: return "Normal" + } + } + + var locationString: String { + guard let location else { return Self.unavailable } + let lat = String(format: "%.6f°%@", abs(location.latitude), location.latitude >= 0 ? "N" : "S") + let lon = String(format: "%.6f°%@", abs(location.longitude), location.longitude >= 0 ? "E" : "W") + return "\(lat), \(lon)" + } + + var durationString: String { + guard duration > 0 else { return Self.unavailable } + return duration.formattedTime // existing TimeInterval extension (VideoPlayerView.swift) + } + + var codecString: String { codec ?? Self.unavailable } + + var frameRateString: String { + guard let frameRate, frameRate > 0 else { return Self.unavailable } + return String(format: "%.0f fps", frameRate) + } + + var bitrateString: String { + guard let bitrate, bitrate > 0 else { return Self.unavailable } + let mbps = Double(bitrate) / 1_000_000 + if mbps >= 1 { return String(format: "%.1f Mbps", mbps) } + return String(format: "%.0f Kbps", Double(bitrate) / 1_000) + } +} diff --git a/SnapSafe/Data/PIN/HashedPin.swift b/SnapSafe/Data/PIN/HashedPin.swift index 08e1754..6ef1545 100644 --- a/SnapSafe/Data/PIN/HashedPin.swift +++ b/SnapSafe/Data/PIN/HashedPin.swift @@ -5,7 +5,7 @@ // Created by Adam Brown on 9/2/25. // -public struct HashedPin: Codable, Equatable { +struct HashedPin: Codable, Equatable, Sendable { let hash: String let salt: String } diff --git a/SnapSafe/Data/PIN/PinCrypto.swift b/SnapSafe/Data/PIN/PinCrypto.swift index 6923c93..4fc4c1c 100644 --- a/SnapSafe/Data/PIN/PinCrypto.swift +++ b/SnapSafe/Data/PIN/PinCrypto.swift @@ -13,7 +13,7 @@ import Logging @Mockable protocol PinCrypto: Sendable { - func hashPin(pin: String, deviceId: Data) -> HashedPin + func hashPin(pin: String, deviceId: Data) throws -> HashedPin func verifyPin(pin: String, stored: HashedPin, deviceId: Data) -> Bool } @@ -30,13 +30,13 @@ final class PinCryptoImpl: PinCrypto { } /// Hashes PIN bound to `deviceId` (pinBytes + deviceIdBytes), returning base64url-wrapped Argon2 **encoded** string + salt. - func hashPin(pin: String, deviceId: Data) -> HashedPin { + func hashPin(pin: String, deviceId: Data) throws -> HashedPin { let salt = Data.random(bytes: 16) var password = Data(pin.utf8) password.append(deviceId) - let digest = try! Argon2.hash( + let digest = try Argon2.hash( password: password, salt: salt, iterations: iterations, diff --git a/SnapSafe/Data/PIN/PinRepository.swift b/SnapSafe/Data/PIN/PinRepository.swift index f56df7a..389e7eb 100644 --- a/SnapSafe/Data/PIN/PinRepository.swift +++ b/SnapSafe/Data/PIN/PinRepository.swift @@ -8,19 +8,19 @@ import Mockable @Mockable -public protocol PinRepository: Sendable { +protocol PinRepository: Sendable { // MARK: - Core PIN APIs func setAppPin(_ pin: String) async func getHashedPin() async -> HashedPin? - func hashPin(_ pin: String) async -> HashedPin + func hashPin(_ pin: String) async throws -> HashedPin func verifyPin(inputPin: String, storedHash: HashedPin) async -> Bool func verifyPoisonPillPin(_ pin: String) async -> Bool - + func verifySecurityPin(_ pin: String) async -> Bool func hasPoisonPillPin() async -> Bool - + // MARK: - Poison Pill APIs func setPoisonPillPin(_ pin: String) async @@ -30,5 +30,5 @@ public protocol PinRepository: Sendable { func removePoisonPillPin() async } -let MIN_PIN_LENGTH = 4 -let MAX_PIN_LENGTH = 10 +let minPinLength = 4 +let maxPinLength = 16 diff --git a/SnapSafe/Data/PIN/PinRepositoryImpl.swift b/SnapSafe/Data/PIN/PinRepositoryImpl.swift index 2d9df02..c762ef6 100644 --- a/SnapSafe/Data/PIN/PinRepositoryImpl.swift +++ b/SnapSafe/Data/PIN/PinRepositoryImpl.swift @@ -24,14 +24,12 @@ class PinRepositoryImpl: PinRepository, @unchecked Sendable { } func setAppPin(_ pin: String) async { - let hashedPin = await hashPin(pin) - do { + let hashedPin = try await hashPin(pin) let hashedPinData = try jsonEncoder().encode(hashedPin) let cipheredHash = try await encryptionScheme.encryptWithKeyAlias( plain: hashedPinData, keyAlias: Self.PIN_KEY_ALIAS) let cipheredHashBase64 = cipheredHash.base64EncodedString() - await dataSource.setAppPin(cipheredPin: cipheredHashBase64) } catch { Logger.storage.error("Failed to store app pin: \(error)") @@ -68,8 +66,8 @@ class PinRepositoryImpl: PinRepository, @unchecked Sendable { return await verifyPin(inputPin: pin, storedHash: storedHashedPin) } - func hashPin(_ pin: String) async -> HashedPin { - return pinCrypto.hashPin(pin: pin, deviceId: await deviceInfo.getDeviceIdentifier()) + func hashPin(_ pin: String) async throws -> HashedPin { + return try pinCrypto.hashPin(pin: pin, deviceId: await deviceInfo.getDeviceIdentifier()) } func verifyPin(inputPin: String, storedHash: HashedPin) async -> Bool { @@ -93,15 +91,14 @@ class PinRepositoryImpl: PinRepository, @unchecked Sendable { } func setPoisonPillPin(_ pin: String) async { - let hashedPin = await hashPin(pin) - do { + let hashedPin = try await hashPin(pin) let hashedPinData = try jsonEncoder().encode(hashedPin) Logger.security.debug("Setting poison pill PIN", metadata: [ "hashedPinDataSize": .stringConvertible(hashedPinData.count) ]) - + let cipheredHashedPpp = try await encryptionScheme.encryptWithKeyAlias( plain: hashedPinData, keyAlias: Self.PIN_KEY_ALIAS) let cipheredHashedPppBase64 = cipheredHashedPpp.base64EncodedString() diff --git a/SnapSafe/Data/SecureImage/ImageMetadataTypes.swift b/SnapSafe/Data/SecureImage/ImageMetadataTypes.swift new file mode 100644 index 0000000..8dd10a7 --- /dev/null +++ b/SnapSafe/Data/SecureImage/ImageMetadataTypes.swift @@ -0,0 +1,30 @@ +// +// ImageMetadataTypes.swift +// SnapSafe +// +// Value types describing image metadata parsed from JPEG data. Shared between +// ImageProcessing (which parses them) and SecureImageRepository (which surfaces +// them via PhotoMetaData). +// + +struct ParsedImageMetadata { + let width: Int? + let height: Int? + let orientation: TiffOrientation? + let gps: GpsCoordinates? +} + +struct Size { + let width: Int + let height: Int +} + +enum TiffOrientation: Int { + case up = 1, upMirrored = 2, down = 3, downMirrored = 4 + case leftMirrored = 5, right = 6, rightMirrored = 7, left = 8 +} + +struct GpsCoordinates { + let latitude: Double + let longitude: Double +} diff --git a/SnapSafe/Data/SecureImage/ImageProcessing.swift b/SnapSafe/Data/SecureImage/ImageProcessing.swift new file mode 100644 index 0000000..4d4c188 --- /dev/null +++ b/SnapSafe/Data/SecureImage/ImageProcessing.swift @@ -0,0 +1,251 @@ +// +// ImageProcessing.swift +// SnapSafe +// +// Pure image/EXIF utilities extracted from SecureImageRepository. No file I/O, +// no encryption, no shared state — a stateless namespace so callers (and the +// off-main repository actor in a later phase) can run CPU-bound image work +// without touching the data or UI layers. +// +// NOTE: rotate/resize use the UIGraphics image-context API exactly as the +// original code did. These run on the caller's context today (the repository +// is still @MainActor). When the repository becomes an off-main actor in PR3, +// re-verify thread safety or migrate these two to UIGraphicsImageRenderer. +// + +import AVFoundation +import CoreLocation +import ImageIO +import Logging +import UIKit +import UniformTypeIdentifiers + +enum ImageProcessing { + + /// Compresses a UIImage to JPEG data with the given quality. + static func compressImageToJpeg(_ image: UIImage, quality: CGFloat) -> Data? { + image.jpegData(compressionQuality: quality) + } + + /// Rotates a UIImage by the given degrees. + static func rotateImage(_ image: UIImage, degrees: Int) -> UIImage { + let radians = CGFloat(degrees) * .pi / 180 + + var newSize = CGRect(origin: CGPoint.zero, size: image.size) + .applying(CGAffineTransform(rotationAngle: radians)).size + newSize.width = floor(newSize.width) + newSize.height = floor(newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, image.scale) + guard let context = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return image + } + + context.translateBy(x: newSize.width / 2, y: newSize.height / 2) + context.rotate(by: radians) + + image.draw(in: CGRect(x: -image.size.width / 2, y: -image.size.height / 2, + width: image.size.width, height: image.size.height)) + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage ?? image + } + + /// Resizes a UIImage to the specified size. + static func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage { + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + image.draw(in: CGRect(origin: .zero, size: size)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return resizedImage ?? image + } + + /// Converts rotation degrees to CGImagePropertyOrientation. + static func cgImageOrientation(from degrees: Int) -> CGImagePropertyOrientation { + switch degrees { + case 90: return .right + case 180: return .down + case 270: return .left + default: return .up + } + } + + /// Writes timestamp / orientation / GPS metadata into JPEG data. + static func applyImageMetadata( + _ imageData: Data, + location: CLLocation?, + applyRotation: Bool, + rotationDegrees: Int + ) -> Data { + guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + return imageData + } + + let mutableData = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(mutableData, UTType.jpeg.identifier as CFString, 1, nil) else { + return imageData + } + + var properties: [String: Any] = [:] + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + properties[kCGImagePropertyExifDateTimeOriginal as String] = formatter.string(from: Date()) + + if !applyRotation { + let orientation = cgImageOrientation(from: rotationDegrees) + properties[kCGImagePropertyOrientation as String] = orientation.rawValue + } + + if let location = location { + let gpsInfo: [String: Any] = [ + kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude), + kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S", + kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude), + kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W" + ] + properties[kCGImagePropertyGPSDictionary as String] = gpsInfo + } + + CGImageDestinationAddImage(destination, image, properties as CFDictionary) + CGImageDestinationFinalize(destination) + + return mutableData as Data + } + + /// Extracts orientation/EXIF/TIFF/GPS metadata dictionaries from JPEG data. + static func extractEXIFMetadata(from imageData: Data) -> [String: Any] { + var exifMetadata: [String: Any] = [:] + + guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] else { + return exifMetadata + } + + if let orientation = imageProperties[kCGImagePropertyOrientation as String] as? Int { + exifMetadata[kCGImagePropertyOrientation as String] = orientation + } + if let exifDict = imageProperties[kCGImagePropertyExifDictionary as String] as? [String: Any] { + exifMetadata[kCGImagePropertyExifDictionary as String] = exifDict + } + if let tiffDict = imageProperties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] { + exifMetadata[kCGImagePropertyTIFFDictionary as String] = tiffDict + } + if let gpsDict = imageProperties[kCGImagePropertyGPSDictionary as String] as? [String: Any] { + exifMetadata[kCGImagePropertyGPSDictionary as String] = gpsDict + } + + return exifMetadata + } + + /// Re-encodes image data to JPEG, preserving the supplied EXIF metadata. + static func processImageWithEXIFMetadata( + imageData: Data, + preservedEXIFMetadata: [String: Any], + filename _: String + ) throws -> Data { + guard let image = UIImage(data: imageData) else { + throw ImageRepositoryError.invalidImageData + } + + guard let jpegData = image.jpegData(compressionQuality: 0.9) else { + throw ImageRepositoryError.compressionFailed + } + + if preservedEXIFMetadata.isEmpty { + return jpegData + } + + let mutableData = NSMutableData(data: jpegData) + let type = UTType.jpeg.identifier as CFString + guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, 1, nil) else { + return jpegData + } + + guard let source = CGImageSourceCreateWithData(jpegData as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + return jpegData + } + + CGImageDestinationAddImage(destination, cgImage, preservedEXIFMetadata as CFDictionary) + + if CGImageDestinationFinalize(destination) { + return mutableData as Data + } + + return jpegData + } + + /// Decodes a JPEG image, scales it to 1/`scale` of its original dimensions, and re-encodes to JPEG. + /// Returns nil if the input is not valid JPEG data. + static func createThumbnailData(fromJPEGData data: Data, scale: CGFloat = 4.0) -> Data? { + guard scale > 0, let image = UIImage(data: data) else { return nil } + let size = CGSize(width: image.size.width / scale, height: image.size.height / scale) + let resized = resizeImage(image, to: size) + return resized.jpegData(compressionQuality: 0.75) + } + + /// Generates a JPEG thumbnail from a plaintext video file using AVAssetImageGenerator. + /// Returns nil if the video has no frames or generation fails. + static func generateVideoThumbnailJPEG(fromVideoAt url: URL) async -> Data? { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 600, height: 600) + // Allow some tolerance so very short clips still yield a frame. + generator.requestedTimeToleranceBefore = CMTime(seconds: 1, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 1, preferredTimescale: 600) + do { + let result = try await generator.image(at: CMTime(seconds: 0, preferredTimescale: 600)) + let image = UIImage(cgImage: result.image) + return image.jpegData(compressionQuality: 0.7) + } catch { + Logger.storage.error("AVAssetImageGenerator failed: \(error)") + return nil + } + } + + /// Parses pixel dimensions, orientation, and GPS coordinates out of JPEG data. + static func readImageMetadata(fromJPEGData data: Data) -> ParsedImageMetadata? { + guard let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] + + let pixelWidth = (props?[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue + let pixelHeight = (props?[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue + + var orientation: TiffOrientation? = nil + if let tiff = props?[kCGImagePropertyTIFFDictionary] as? [CFString: Any], + let ori = (tiff[kCGImagePropertyTIFFOrientation] as? NSNumber)?.intValue, + let mapped = TiffOrientation(rawValue: ori) { + orientation = mapped + } else if let ori = (props?[kCGImagePropertyOrientation] as? NSNumber)?.intValue, + let mapped = TiffOrientation(rawValue: ori) { + // Some writers put orientation at the top level + orientation = mapped + } + + var gpsCoords: GpsCoordinates? = nil + if let gps = props?[kCGImagePropertyGPSDictionary] as? [CFString: Any] { + if let lat = gps[kCGImagePropertyGPSLatitude] as? NSNumber, + let latRef = gps[kCGImagePropertyGPSLatitudeRef] as? String, + let lon = gps[kCGImagePropertyGPSLongitude] as? NSNumber, + let lonRef = gps[kCGImagePropertyGPSLongitudeRef] as? String { + let latSign = (latRef.uppercased() == "S") ? -1.0 : 1.0 + let lonSign = (lonRef.uppercased() == "W") ? -1.0 : 1.0 + gpsCoords = GpsCoordinates(latitude: lat.doubleValue * latSign, + longitude: lon.doubleValue * lonSign) + } + } + + return ParsedImageMetadata( + width: pixelWidth, + height: pixelHeight, + orientation: orientation, + gps: gpsCoords + ) + } +} diff --git a/SnapSafe/Data/SecureImage/PhotoStorageDataSource.swift b/SnapSafe/Data/SecureImage/PhotoStorageDataSource.swift new file mode 100644 index 0000000..b8d7275 --- /dev/null +++ b/SnapSafe/Data/SecureImage/PhotoStorageDataSource.swift @@ -0,0 +1,220 @@ +// +// PhotoStorageDataSource.swift +// SnapSafe +// +// Stateless storage layer for encrypted media: owns the on-disk directory +// layout (with backup exclusion) and raw encrypt-to-file / decrypt-from-file +// operations. SecureImageRepository delegates all filesystem + crypto-at-rest +// work here and keeps the domain logic. +// + +import Foundation +import Logging + +struct PhotoStorageDataSource { + + // MARK: - Directory names (single source of truth) + + static let photosDir = "photos" + static let decoysDir = "decoys" + static let videosDir = "videos" + static let videoThumbnailsDir = "videoThumbnails" + static let decoyVideoThumbnailsDir = "decoyVideoThumbnails" + static let thumbnailsDir = ".thumbnails" + + // MARK: - Dependencies + + private let encryptionScheme: EncryptionScheme + /// Roots every storage directory is derived from. Injected so hosted unit + /// tests can point at a temp directory instead of the real app container. + private let appSupportRoot: URL + private let cachesRoot: URL + + init(encryptionScheme: EncryptionScheme, appSupportRoot: URL, cachesRoot: URL) { + self.encryptionScheme = encryptionScheme + self.appSupportRoot = appSupportRoot + self.cachesRoot = cachesRoot + } + + // MARK: - Directory Management + + func getGalleryDirectory() -> URL { + var galleryDir = appSupportRoot.appendingPathComponent(Self.photosDir) + + // Create directory and exclude from backup + do { + try FileManager.default.createDirectory(at: galleryDir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try galleryDir.setResourceValues(resourceValues) + } catch { + Logger.storage.error("Failed to setup gallery directory: \(error)") + } + + return galleryDir + } + + func getDecoyDirectory() -> URL { + var decoyDir = appSupportRoot.appendingPathComponent(Self.decoysDir) + + // Create directory and exclude from backup + do { + try FileManager.default.createDirectory(at: decoyDir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try decoyDir.setResourceValues(resourceValues) + } catch { + Logger.storage.error("Failed to setup decoy directory: \(error)") + } + + return decoyDir + } + + func getVideosDirectory() -> URL { + var videosDir = appSupportRoot.appendingPathComponent(Self.videosDir) + + // Create directory and exclude from backup + do { + try FileManager.default.createDirectory(at: videosDir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try videosDir.setResourceValues(resourceValues) + } catch { + Logger.storage.error("Failed to setup videos directory: \(error)") + } + + return videosDir + } + + /// Durable, encrypted storage for video thumbnails. Unlike photo thumbnails + /// (regenerated from the encrypted photo on demand), video thumbnails are + /// generated once at record time from the plaintext `.mov` and cannot be + /// recreated afterwards, so they live in Application Support rather than the + /// purgeable caches directory. + func getVideoThumbnailsDirectory() -> URL { + var dir = appSupportRoot.appendingPathComponent(Self.videoThumbnailsDir) + + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try dir.setResourceValues(resourceValues) + } catch { + Logger.storage.error("Failed to setup video thumbnails directory: \(error)") + } + + return dir + } + + /// Decoy video thumbnails: re-encrypted with the poison-pill key at mark time + /// and restored into `videoThumbnails/` when the poison pill activates (the + /// real-key thumbnails are destroyed then, so decoy videos would otherwise + /// lose their thumbnail). Kept separate so it is not wiped by + /// `deleteAllVideoThumbnails()` or the decoy directory cleanup. + func getDecoyVideoThumbnailsDirectory() -> URL { + var dir = appSupportRoot.appendingPathComponent(Self.decoyVideoThumbnailsDir) + + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try dir.setResourceValues(resourceValues) + } catch { + Logger.storage.error("Failed to setup decoy video thumbnails directory: \(error)") + } + + return dir + } + + func getThumbnailsDirectory() -> URL { + let thumbnailsDir = cachesRoot.appendingPathComponent(Self.thumbnailsDir) + + if !FileManager.default.fileExists(atPath: thumbnailsDir.path) { + try? FileManager.default.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true) + } + + return thumbnailsDir + } + + // MARK: - Raw encrypted file I/O + + /// Encrypts and saves data to a file. + func encryptToFile(_ data: Data, targetFile: URL) async throws { + try await encryptionScheme.encryptToFile(plain: data, targetFile: targetFile) + Logger.storage.info("Saved image to file: \(targetFile.path)") + } + + /// Decrypts a file and returns the data. + func decryptFile(_ encryptedFile: URL) async throws -> Data { + return try await encryptionScheme.decryptFile(encryptedFile) + } + + /// Encrypts data to a temp file, then atomically moves it to the target file. + func encryptAndSaveImage(_ imageData: Data, tempFile: URL, targetFile: URL) async throws { + // Remove files if they exist + try? FileManager.default.removeItem(at: tempFile) + try? FileManager.default.removeItem(at: targetFile) + + // Encrypt to temp file + try await encryptToFile(imageData, targetFile: tempFile) + + // Move temp file to target + try FileManager.default.moveItem(at: tempFile, to: targetFile) + } + + // MARK: - File paths + + func getThumbnailFile(_ photoDef: PhotoDef) -> URL { + return getThumbnailsDirectory().appendingPathComponent(photoDef.photoName) + } + + func getDecoyFile(_ photoDef: PhotoDef) -> URL { + return getDecoyDirectory().appendingPathComponent(photoDef.photoName) + } + + func getDecoyVideoFile(_ videoDef: VideoDef) -> URL { + return getDecoyDirectory().appendingPathComponent(videoDef.videoFile.lastPathComponent) + } + + func getVideoThumbnailFile(forVideoNamed name: String) -> URL { + return getVideoThumbnailsDirectory().appendingPathComponent(name).appendingPathExtension("jpg") + } + + func getDecoyVideoThumbnailFile(forVideoNamed name: String) -> URL { + return getDecoyVideoThumbnailsDirectory().appendingPathComponent(name).appendingPathExtension("jpg") + } + + // MARK: - Decoy file enumeration + + /// Decoy photo files (`.jpg`) currently in the decoy directory. + func getDecoyFiles() -> [URL] { + let dir = getDecoyDirectory() + + guard FileManager.default.fileExists(atPath: dir.path) else { + return [] + } + + do { + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + return files.filter { $0.hasDirectoryPath == false && $0.pathExtension == "jpg" } + } catch { + return [] + } + } + + /// Decoy video files (`.secv`) currently in the decoy directory. + func getDecoyVideoFiles() -> [URL] { + let dir = getDecoyDirectory() + + guard FileManager.default.fileExists(atPath: dir.path) else { + return [] + } + + do { + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + return files.filter { $0.hasDirectoryPath == false && $0.pathExtension.lowercased() == "secv" } + } catch { + return [] + } + } +} diff --git a/SnapSafe/Data/SecureImage/SecureImageRepository.swift b/SnapSafe/Data/SecureImage/SecureImageRepository.swift index dbb2a3e..9a78917 100644 --- a/SnapSafe/Data/SecureImage/SecureImageRepository.swift +++ b/SnapSafe/Data/SecureImage/SecureImageRepository.swift @@ -9,99 +9,92 @@ import Foundation import Logging import UIKit import CoreLocation -import UniformTypeIdentifiers -import ImageIO +import CryptoKit +import AVFoundation + +actor SecureImageRepository { -@MainActor -public class SecureImageRepository { - // MARK: - Constants - - static let photosDir = "photos" - static let decoysDir = "decoys" - static let thumbnailsDir = ".thumbnails" - static let maxDecoyPhotos = 10 - + + // Directory names live on PhotoStorageDataSource; these aliases preserve the + // existing `SecureImageRepository.` references (used by tests). + static let photosDir = PhotoStorageDataSource.photosDir + static let decoysDir = PhotoStorageDataSource.decoysDir + static let videosDir = PhotoStorageDataSource.videosDir + static let videoThumbnailsDir = PhotoStorageDataSource.videoThumbnailsDir + static let decoyVideoThumbnailsDir = PhotoStorageDataSource.decoyVideoThumbnailsDir + /// The single source of truth for the maximum number of decoys a user may + /// have. The limit is shared across photos AND videos combined; the current + /// total is `numDecoys()`. All add-decoy paths must enforce this constant. + static let maxDecoyItems = 10 + // MARK: - Dependencies - - let thumbnailCache: ThumbnailCache + + nonisolated let thumbnailCache: ThumbnailCache private let encryptionScheme: EncryptionScheme - + private let videoEncryptionService: VideoEncryptionServiceProtocol + private let storage: PhotoStorageDataSource + // MARK: - Initialization - - init(thumbnailCache: ThumbnailCache, encryptionScheme: EncryptionScheme) { + + init( + thumbnailCache: ThumbnailCache, + encryptionScheme: EncryptionScheme, + videoEncryptionService: VideoEncryptionServiceProtocol = VideoEncryptionService(), + applicationSupportDirectory: URL? = nil, + cachesDirectory: URL? = nil + ) { self.thumbnailCache = thumbnailCache self.encryptionScheme = encryptionScheme + self.videoEncryptionService = videoEncryptionService + let appSupportRoot = applicationSupportDirectory + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let cachesRoot = cachesDirectory + ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + self.storage = PhotoStorageDataSource( + encryptionScheme: encryptionScheme, appSupportRoot: appSupportRoot, cachesRoot: cachesRoot) } - + // MARK: - Directory Management - - func getGalleryDirectory() -> URL { - let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - var galleryDir = appSupportPath.appendingPathComponent(Self.photosDir) - - // Create directory and exclude from backup - do { - try FileManager.default.createDirectory(at: galleryDir, withIntermediateDirectories: true, attributes: nil) - var resourceValues = URLResourceValues() - resourceValues.isExcludedFromBackup = true - try galleryDir.setResourceValues(resourceValues) - } catch { - Logger.storage.error("Failed to setup gallery directory: \(error)") - } - - return galleryDir - } - - func getDecoyDirectory() -> URL { - let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - var decoyDir = appSupportPath.appendingPathComponent(Self.decoysDir) - - // Create directory and exclude from backup - do { - try FileManager.default.createDirectory(at: decoyDir, withIntermediateDirectories: true, attributes: nil) - var resourceValues = URLResourceValues() - resourceValues.isExcludedFromBackup = true - try decoyDir.setResourceValues(resourceValues) - } catch { - Logger.storage.error("Failed to setup decoy directory: \(error)") - } - - return decoyDir - } - - private func getThumbnailsDirectory() -> URL { - let cachesPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let thumbnailsDir = cachesPath.appendingPathComponent(Self.thumbnailsDir) - - if !FileManager.default.fileExists(atPath: thumbnailsDir.path) { - try? FileManager.default.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true) - } - - return thumbnailsDir - } - + + func getGalleryDirectory() -> URL { storage.getGalleryDirectory() } + func getDecoyDirectory() -> URL { storage.getDecoyDirectory() } + func getVideosDirectory() -> URL { storage.getVideosDirectory() } + func getVideoThumbnailsDirectory() -> URL { storage.getVideoThumbnailsDirectory() } + func getDecoyVideoThumbnailsDirectory() -> URL { storage.getDecoyVideoThumbnailsDirectory() } + private func getThumbnailsDirectory() -> URL { storage.getThumbnailsDirectory() } + // MARK: - Security Operations - - func evictKey() { - encryptionScheme.evictKey() + + func evictKey() async { + await encryptionScheme.evictKey() } - + /// Resets all security-related data when a security failure occurs. /// Deletes all images and thumbnails and evicts all in-memory data. - func securityFailureReset() { + func securityFailureReset() async { deleteAllImages() + deleteAllVideoThumbnails() + deleteAllDecoyVideoThumbnails() clearAllThumbnails() - evictKey() + await evictKey() } - + /// Deletes all images that haven't been flagged as benign - func activatePoisonPill() { + func activatePoisonPill() async { + // Delete non-decoy videos first, while the decoy directory is still + // intact (deleteNonDecoyImages() consumes and removes that directory). + deleteNonDecoyVideos() deleteNonDecoyImages() + // Video thumbnails are derived from real video frames; destroy them all, + // then restore the poison-pill-key thumbnails for the surviving decoy + // videos so they still show a thumbnail in the gallery. + deleteAllVideoThumbnails() + restoreDecoyVideoThumbnails() clearAllThumbnails() - evictKey() + await evictKey() } - + private func clearAllThumbnails() { let thumbnailsDir = getThumbnailsDirectory() do { @@ -111,95 +104,17 @@ public class SecureImageRepository { } thumbnailCache.clear() } - + // MARK: - Image Operations - - /// Encrypts and saves image data to a file - private func encryptToFile(_ data: Data, targetFile: URL) async throws { - try await encryptionScheme.encryptToFile(plain: data, targetFile: targetFile) - Logger.storage.info("Saved image to file: \(targetFile.path)") - } - - /// Decrypts a file and returns the data + private func decryptFile(_ encryptedFile: URL) async throws -> Data { - return try await encryptionScheme.decryptFile(encryptedFile) - } - - /// Compresses a UIImage to JPEG format with specified quality - private func compressImageToJpeg(_ image: UIImage, quality: CGFloat) -> Data? { - return image.jpegData(compressionQuality: quality) + try await storage.decryptFile(encryptedFile) } - - /// Encrypts and saves image data to a file, then renames it to the target file + private func encryptAndSaveImage(_ imageData: Data, tempFile: URL, targetFile: URL) async throws { - // Remove files if they exist - try? FileManager.default.removeItem(at: tempFile) - try? FileManager.default.removeItem(at: targetFile) - - // Encrypt to temp file - try await encryptToFile(imageData, targetFile: tempFile) - - // Move temp file to target - try FileManager.default.moveItem(at: tempFile, to: targetFile) - } - - /// Applies metadata to an image - private func applyImageMetadata( - _ imageData: Data, - location: CLLocation?, - applyRotation: Bool, - rotationDegrees: Int - ) -> Data { - guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), - let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - return imageData - } - - let mutableData = NSMutableData() - guard let destination = CGImageDestinationCreateWithData(mutableData, UTType.jpeg.identifier as CFString, 1, nil) else { - return imageData - } - - var properties: [String: Any] = [:] - - // Add current timestamp - let formatter = DateFormatter() - formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" - properties[kCGImagePropertyExifDateTimeOriginal as String] = formatter.string(from: Date()) - - // Add orientation - if !applyRotation { - let orientation = cgImageOrientation(from: rotationDegrees) - properties[kCGImagePropertyOrientation as String] = orientation.rawValue - } - - // Add GPS location if available - if let location = location { - let gpsInfo: [String: Any] = [ - kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude), - kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S", - kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude), - kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W" - ] - properties[kCGImagePropertyGPSDictionary as String] = gpsInfo - } - - CGImageDestinationAddImage(destination, image, properties as CFDictionary) - CGImageDestinationFinalize(destination) - - return mutableData as Data - } - - /// Converts rotation degrees to CGImagePropertyOrientation - private func cgImageOrientation(from degrees: Int) -> CGImagePropertyOrientation { - switch degrees { - case 90: return .right - case 180: return .down - case 270: return .left - default: return .up - } - } - + try await storage.encryptAndSaveImage(imageData, tempFile: tempFile, targetFile: targetFile) + } + /// Saves a captured image to the gallery func saveImage( _ image: CapturedImage, @@ -208,98 +123,60 @@ public class SecureImageRepository { quality: CGFloat = 0.9 ) async throws -> PhotoDef { let dir = getGalleryDirectory() - + // Create directory if it doesn't exist if !FileManager.default.fileExists(atPath: dir.path) { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } - + // Generate filename let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyyMMdd_HHmmss_SS" dateFormatter.locale = Locale(identifier: "en_US_POSIX") let filename = "photo_\(dateFormatter.string(from: image.timestamp)).jpg" - + let photoFile = dir.appendingPathComponent(filename) let tempFile = dir.appendingPathComponent("\(filename).tmp") - + // Process image var processedImage = image.sensorBitmap if applyRotation { - processedImage = rotateImage(image.sensorBitmap, degrees: image.rotationDegrees) + processedImage = ImageProcessing.rotateImage(image.sensorBitmap, degrees: image.rotationDegrees) } - + // Compress to JPEG - guard let jpegData = compressImageToJpeg(processedImage, quality: quality) else { + guard let jpegData = ImageProcessing.compressImageToJpeg(processedImage, quality: quality) else { throw ImageRepositoryError.compressionFailed } - + // Apply metadata - let updatedData = applyImageMetadata(jpegData, location: location, applyRotation: applyRotation, rotationDegrees: image.rotationDegrees) - + let updatedData = ImageProcessing.applyImageMetadata(jpegData, location: location, applyRotation: applyRotation, rotationDegrees: image.rotationDegrees) + // Encrypt and save try await encryptAndSaveImage(updatedData, tempFile: tempFile, targetFile: photoFile) - + return PhotoDef(photoName: filename, photoFormat: "jpg", photoFile: photoFile) } - - /// Rotates a UIImage by the specified degrees - private func rotateImage(_ image: UIImage, degrees: Int) -> UIImage { - let radians = CGFloat(degrees) * .pi / 180 - - var newSize = CGRect(origin: CGPoint.zero, size: image.size).applying(CGAffineTransform(rotationAngle: radians)).size - newSize.width = floor(newSize.width) - newSize.height = floor(newSize.height) - - UIGraphicsBeginImageContextWithOptions(newSize, false, image.scale) - let context = UIGraphicsGetCurrentContext()! - - context.translateBy(x: newSize.width/2, y: newSize.height/2) - context.rotate(by: radians) - - image.draw(in: CGRect(x: -image.size.width/2, y: -image.size.height/2, width: image.size.width, height: image.size.height)) - - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage ?? image - } - - /// Reads and decrypts an image file - func readImage(_ photo: PhotoDef) async throws -> UIImage { - let data = try await decryptFile(photo.photoFile) - guard let image = UIImage(data: data) else { - throw ImageRepositoryError.invalidImageData - } - return image - } - + + /// Reads and decrypts an image file, returning raw JPEG data + func readImage(_ photo: PhotoDef) async throws -> Data { + return try await storage.decryptFile(photo.photoFile) + } + /// Decrypts and returns JPEG data func decryptJpg(_ photo: PhotoDef) async throws -> Data { return try await decryptFile(photo.photoFile) } - + // MARK: - Thumbnail Operations - - private func getThumbnailFile(_ photoDef: PhotoDef) -> URL { - return getThumbnailsDirectory().appendingPathComponent(photoDef.photoName) - } - - /// Reads or creates a thumbnail for the given photo - func readThumbnail(_ photo: PhotoDef) async -> UIImage? { - // Check cache first - if let cachedThumbnail = thumbnailCache.getThumbnail(photo) { - return cachedThumbnail - } - - let thumbFile = getThumbnailFile(photo) - var thumbnailImage: UIImage? - + + /// Reads or creates a thumbnail for the given photo, returning raw JPEG data + func readThumbnail(_ photo: PhotoDef) async -> Data? { + let thumbFile = storage.getThumbnailFile(photo) + if FileManager.default.fileExists(atPath: thumbFile.path) { - // Decrypt existing thumbnail do { - let data = try await decryptFile(thumbFile) - thumbnailImage = UIImage(data: data) + return try await storage.decryptFile(thumbFile) } catch { Logger.storage.error("Failed to decrypt thumbnail", metadata: [ "photoName": .string(photo.photoName), @@ -307,57 +184,36 @@ public class SecureImageRepository { ]) return nil } - } else if FileManager.default.fileExists(atPath: photo.photoFile.path) { - // Create thumbnail from full image - do { - let data = try await decryptFile(photo.photoFile) - guard let fullImage = UIImage(data: data) else { return nil } - - // Create smaller thumbnail - let thumbnailSize = CGSize(width: fullImage.size.width / 4, height: fullImage.size.height / 4) - thumbnailImage = resizeImage(fullImage, to: thumbnailSize) - - // Cache thumbnail to file - if let thumbnailImage = thumbnailImage, - let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.75) { - try await encryptToFile(thumbnailData, targetFile: thumbFile) - } - } catch { - Logger.storage.error("Failed to create thumbnail", metadata: [ - "photoName": .string(photo.photoName), - "error": .string(String(describing: error)) - ]) - return nil - } } - - // Cache in memory - if let thumbnailImage = thumbnailImage { - thumbnailCache.putThumbnail(photo, thumbnailImage) + + guard FileManager.default.fileExists(atPath: photo.photoFile.path) else { return nil } + + do { + let fullData = try await storage.decryptFile(photo.photoFile) + guard let thumbnailData = ImageProcessing.createThumbnailData(fromJPEGData: fullData) else { return nil } + // Reentrancy note: two concurrent calls for the same photo may both create the + // thumbnail file; the last write wins and both return valid data. + try await storage.encryptToFile(thumbnailData, targetFile: thumbFile) + return thumbnailData + } catch { + Logger.storage.error("Failed to create thumbnail", metadata: [ + "photoName": .string(photo.photoName), + "error": .string(String(describing: error)) + ]) + return nil } - - return thumbnailImage - } - - /// Resizes an image to the specified size - private func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage { - UIGraphicsBeginImageContextWithOptions(size, false, 0.0) - image.draw(in: CGRect(origin: .zero, size: size)) - let resizedImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return resizedImage ?? image } - + // MARK: - Photo Management - + /// Gets all photos in the gallery func getPhotos() -> [PhotoDef] { let dir = getGalleryDirectory() - + guard FileManager.default.fileExists(atPath: dir.path) else { return [] } - + do { let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) return files @@ -376,19 +232,19 @@ public class SecureImageRepository { return [] } } - + /// Deletes a single image @discardableResult func deleteImage(_ photoDef: PhotoDef, deleteDecoy: Bool = true) -> Bool { thumbnailCache.evictThumbnail(photoDef) - + if deleteDecoy && isDecoyPhoto(photoDef) { - try? FileManager.default.removeItem(at: getDecoyFile(photoDef)) + try? FileManager.default.removeItem(at: storage.getDecoyFile(photoDef)) } - - let thumbnailFile = getThumbnailFile(photoDef) + + let thumbnailFile = storage.getThumbnailFile(photoDef) try? FileManager.default.removeItem(at: thumbnailFile) - + if FileManager.default.fileExists(atPath: photoDef.photoFile.path) { do { try FileManager.default.removeItem(at: photoDef.photoFile) @@ -397,113 +253,353 @@ public class SecureImageRepository { return false } } - + return false } - + /// Deletes multiple images @discardableResult func deleteImages(_ photos: [PhotoDef], deleteDecoy: Bool = true) -> Bool { return photos.allSatisfy { deleteImage($0, deleteDecoy: deleteDecoy) } } - + /// Deletes all images func deleteAllImages(deleteDecoy: Bool = true) { let photos = getPhotos() deleteImages(photos, deleteDecoy: deleteDecoy) } - + /// Deletes all non-decoy images and restores decoys func deleteNonDecoyImages() { let galleryDir = getGalleryDirectory() let thumbnailsDir = getThumbnailsDirectory() - + // Remove all current images and thumbnails try? FileManager.default.removeItem(at: galleryDir) try? FileManager.default.removeItem(at: thumbnailsDir) - + // Recreate directories try? FileManager.default.createDirectory(at: galleryDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: thumbnailsDir, withIntermediateDirectories: true) - + // Move decoy files back to gallery - let decoyFiles = getDecoyFiles() + let decoyFiles = storage.getDecoyFiles() for file in decoyFiles { let targetFile = galleryDir.appendingPathComponent(file.lastPathComponent) try? FileManager.default.moveItem(at: file, to: targetFile) } - + // Remove decoy directory try? FileManager.default.removeItem(at: getDecoyDirectory()) } - - // MARK: - Decoy Operations - - private func getDecoyFile(_ photoDef: PhotoDef) -> URL { - return getDecoyDirectory().appendingPathComponent(photoDef.photoName) - } - - private func getDecoyFiles() -> [URL] { - let dir = getDecoyDirectory() - - guard FileManager.default.fileExists(atPath: dir.path) else { - return [] + + /// Destroys every video that hasn't been flagged as a decoy, and replaces + /// each decoy video with its decoy copy. + /// + /// A decoy video is stored in the decoy directory re-encrypted with the + /// poison-pill key (the original in the videos directory is encrypted with + /// the real key, which the poison pill destroys). So for decoy videos we + /// move the decoy copy into the videos directory, overwriting the original. + /// + /// Must run before `deleteNonDecoyImages()`, which removes the decoy + /// directory this relies on. + private func deleteNonDecoyVideos() { + let videosDir = getVideosDirectory() + let decoyVideoFiles = storage.getDecoyVideoFiles() + let decoyVideoNames = Set(decoyVideoFiles.map { $0.lastPathComponent }) + + // 1. Destroy every video that isn't a decoy. + if let files = try? FileManager.default.contentsOfDirectory(at: videosDir, includingPropertiesForKeys: nil) { + for file in files where !decoyVideoNames.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } } - - do { - let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) - return files.filter { $0.hasDirectoryPath == false && $0.pathExtension == "jpg" } - } catch { - return [] + + // 2. Replace each decoy video's original (real-key) file with its + // poison-pill-key copy from the decoy directory. + for decoyFile in decoyVideoFiles { + let target = videosDir.appendingPathComponent(decoyFile.lastPathComponent) + try? FileManager.default.removeItem(at: target) + try? FileManager.default.moveItem(at: decoyFile, to: target) } } - + + // MARK: - Decoy Operations + /// Checks if a photo is marked as decoy func isDecoyPhoto(_ photoDef: PhotoDef) -> Bool { - return FileManager.default.fileExists(atPath: getDecoyFile(photoDef).path) + return FileManager.default.fileExists(atPath: storage.getDecoyFile(photoDef).path) } - - /// Gets the number of decoy photos + + /// Gets the total number of decoys (photos + videos); the limit is shared. func numDecoys() -> Int { - return getDecoyFiles().count + return storage.getDecoyFiles().count + storage.getDecoyVideoFiles().count } - + + // MARK: - Decoy Video Operations + + /// Checks if a video is marked as a decoy. + func isDecoyVideo(_ videoDef: VideoDef) -> Bool { + return FileManager.default.fileExists(atPath: storage.getDecoyVideoFile(videoDef).path) + } + + /// Adds a video as a decoy: decrypts it with the current key and re-encrypts + /// the plaintext with the poison-pill key into the decoy directory, so it + /// remains playable after the poison pill destroys the real key. + func addDecoyVideoWithKey(_ videoDef: VideoDef, keyData: Data) async -> Bool { + guard numDecoys() < Self.maxDecoyItems else { + return false + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mov") + defer { try? FileManager.default.removeItem(at: tempURL) } + + do { + let currentKey = SymmetricKey(data: try await encryptionScheme.getDerivedKey()) + let poisonKey = SymmetricKey(data: keyData) + + let decoyDir = getDecoyDirectory() + if !FileManager.default.fileExists(atPath: decoyDir.path) { + try FileManager.default.createDirectory(at: decoyDir, withIntermediateDirectories: true) + } + + // Decrypt the original (real key) to a temporary plaintext file. + // The video encryption service opens the output via + // FileHandle(forWritingTo:), so the output file must exist first. + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + try await videoEncryptionService.decryptVideoForSharing( + inputURL: videoDef.videoFile, + outputURL: tempURL, + encryptionKey: currentKey + ) + + // Re-encrypt with the poison-pill key into the decoy directory. + let decoyFile = storage.getDecoyVideoFile(videoDef) + if FileManager.default.fileExists(atPath: decoyFile.path) { + try FileManager.default.removeItem(at: decoyFile) + } + FileManager.default.createFile(atPath: decoyFile.path, contents: nil) + try await videoEncryptionService.encryptVideoForDecoy( + inputURL: tempURL, + outputURL: decoyFile, + encryptionKey: poisonKey + ) + + // Preserve a poison-pill-key copy of the thumbnail so the decoy video + // still shows a thumbnail after the poison pill destroys the real one. + await storeDecoyVideoThumbnail(forVideoNamed: videoDef.videoName, poisonKeyData: keyData) + + return true + } catch { + Logger.security.error("Failed to add decoy video: \(error)") + return false + } + } + + /// Removes a video's decoy copy. + @discardableResult + func removeDecoyVideo(_ videoDef: VideoDef) -> Bool { + // Also drop the decoy thumbnail copy (if any). + removeDecoyVideoThumbnail(forVideoNamed: videoDef.videoName) + + let decoyFile = storage.getDecoyVideoFile(videoDef) + guard FileManager.default.fileExists(atPath: decoyFile.path) else { + return false + } + + do { + try FileManager.default.removeItem(at: decoyFile) + return true + } catch { + return false + } + } + + // MARK: - Video Import + + /// Imports a plaintext video (e.g. from the photo library): encrypts it to + /// SECV with the current key in the videos directory and stores a thumbnail. + /// The caller owns `plaintextURL` and should delete it afterwards. + func importVideo(from plaintextURL: URL) async -> Bool { + do { + let key = SymmetricKey(data: try await encryptionScheme.getDerivedKey()) + + // Match the camera's "video_yyyyMMdd_HHmmss" naming so dateTaken() + // parses; bump the second on collision to keep names unique/sortable. + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + + var date = Date() + var name = "video_\(formatter.string(from: date))" + var dest = getVideosDirectory().appendingPathComponent(name).appendingPathExtension("secv") + while FileManager.default.fileExists(atPath: dest.path) { + date = date.addingTimeInterval(1) + name = "video_\(formatter.string(from: date))" + dest = getVideosDirectory().appendingPathComponent(name).appendingPathExtension("secv") + } + + // The encryption service opens its output via FileHandle(forWritingTo:), + // so the file must exist first. + FileManager.default.createFile(atPath: dest.path, contents: nil) + try await videoEncryptionService.encryptVideoForDecoy( + inputURL: plaintextURL, + outputURL: dest, + encryptionKey: key + ) + + await generateAndStoreVideoThumbnail(forVideoNamed: name, fromPlaintextVideo: plaintextURL) + return true + } catch { + Logger.storage.error("Failed to import video: \(error)") + return false + } + } + + // MARK: - Video Thumbnails + + /// Generates a thumbnail from a plaintext video file (e.g. the temporary + /// `.mov` that exists at record time) and stores it encrypted. Call this + /// while the plaintext file still exists; the thumbnail cannot be recreated + /// once the video is encrypted and the plaintext is deleted. + func generateAndStoreVideoThumbnail(forVideoNamed name: String, fromPlaintextVideo url: URL) async { + guard let jpeg = await ImageProcessing.generateVideoThumbnailJPEG(fromVideoAt: url) else { + Logger.storage.error("Failed to generate video thumbnail", metadata: ["video": .string(name)]) + return + } + await storeVideoThumbnail(jpeg, forVideoNamed: name) + } + + /// Stores an already-generated JPEG thumbnail, encrypted with the current key. + func storeVideoThumbnail(_ jpeg: Data, forVideoNamed name: String) async { + do { + let dir = getVideoThumbnailsDirectory() + if !FileManager.default.fileExists(atPath: dir.path) { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + let file = storage.getVideoThumbnailFile(forVideoNamed: name) + try await encryptionScheme.encryptToFile(plain: jpeg, targetFile: file) + } catch { + Logger.storage.error("Failed to store video thumbnail: \(error)") + } + } + + /// Reads (and decrypts) a video's thumbnail, returning raw JPEG data if one exists. + func readVideoThumbnail(_ videoDef: VideoDef) async -> Data? { + let file = storage.getVideoThumbnailFile(forVideoNamed: videoDef.videoName) + guard FileManager.default.fileExists(atPath: file.path) else { return nil } + do { + return try await encryptionScheme.decryptFile(file) + } catch { + Logger.storage.error("Failed to read video thumbnail: \(error)") + return nil + } + } + + func deleteVideoThumbnail(forVideoNamed name: String) { + thumbnailCache.evictVideoThumbnail(name) + try? FileManager.default.removeItem(at: storage.getVideoThumbnailFile(forVideoNamed: name)) + } + + /// Removes all video thumbnails. Used on poison-pill activation and security + /// reset — these thumbnails are derived from real video frames and must be + /// destroyed along with the videos themselves. + func deleteAllVideoThumbnails() { + try? FileManager.default.removeItem(at: getVideoThumbnailsDirectory()) + } + + /// Re-encrypts a video's thumbnail with the poison-pill key and stores it in + /// the decoy video thumbnails directory, so it survives the poison pill (the + /// real-key thumbnail is destroyed then). No-op if the video has no thumbnail. + private func storeDecoyVideoThumbnail(forVideoNamed name: String, poisonKeyData: Data) async { + let thumbFile = storage.getVideoThumbnailFile(forVideoNamed: name) + guard FileManager.default.fileExists(atPath: thumbFile.path) else { return } + do { + let jpeg = try await encryptionScheme.decryptFile(thumbFile) + let dir = getDecoyVideoThumbnailsDirectory() + if !FileManager.default.fileExists(atPath: dir.path) { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + try await encryptionScheme.encryptToFile( + plain: jpeg, + keyBytes: poisonKeyData, + targetFile: storage.getDecoyVideoThumbnailFile(forVideoNamed: name) + ) + } catch { + Logger.security.error("Failed to store decoy video thumbnail: \(error)") + } + } + + private func removeDecoyVideoThumbnail(forVideoNamed name: String) { + try? FileManager.default.removeItem(at: storage.getDecoyVideoThumbnailFile(forVideoNamed: name)) + } + + func deleteAllDecoyVideoThumbnails() { + try? FileManager.default.removeItem(at: getDecoyVideoThumbnailsDirectory()) + } + + /// Moves the poison-pill-key decoy video thumbnails into the (just-wiped) + /// video thumbnails directory. Run after `deleteAllVideoThumbnails()` during + /// poison-pill activation. + private func restoreDecoyVideoThumbnails() { + let decoyDir = getDecoyVideoThumbnailsDirectory() + guard let files = try? FileManager.default.contentsOfDirectory(at: decoyDir, includingPropertiesForKeys: nil), + !files.isEmpty else { + return + } + + let videoThumbsDir = getVideoThumbnailsDirectory() + try? FileManager.default.createDirectory(at: videoThumbsDir, withIntermediateDirectories: true) + + for file in files { + let target = videoThumbsDir.appendingPathComponent(file.lastPathComponent) + try? FileManager.default.removeItem(at: target) + try? FileManager.default.moveItem(at: file, to: target) + } + + try? FileManager.default.removeItem(at: decoyDir) + } + + // MARK: - Decoy Photo Operations + /// Adds a photo as decoy with specific key func addDecoyPhotoWithKey(_ photoDef: PhotoDef, keyData: Data) async -> Bool { - guard numDecoys() < Self.maxDecoyPhotos else { + guard numDecoys() < Self.maxDecoyItems else { return false } - + do { let jpegData = try await decryptJpg(photoDef) let decoyDir = getDecoyDirectory() - + // Create decoy directory if needed if !FileManager.default.fileExists(atPath: decoyDir.path) { try FileManager.default.createDirectory(at: decoyDir, withIntermediateDirectories: true) } - - let decoyFile = getDecoyFile(photoDef) + + let decoyFile = storage.getDecoyFile(photoDef) try await encryptionScheme.encryptToFile( plain: jpegData, keyBytes: keyData, targetFile: decoyFile ) - + return true } catch { return false } } - + /// Removes a decoy photo @discardableResult func removeDecoyPhoto(_ photoDef: PhotoDef) -> Bool { - let decoyFile = getDecoyFile(photoDef) + let decoyFile = storage.getDecoyFile(photoDef) guard FileManager.default.fileExists(atPath: decoyFile.path) else { return false } - + do { try FileManager.default.removeItem(at: decoyFile) return true @@ -511,121 +607,52 @@ public class SecureImageRepository { return false } } - + /// Removes all decoy photos func removeAllDecoyPhotos() { - let decoyFiles = getDecoyFiles() + let decoyFiles = storage.getDecoyFiles() + for file in decoyFiles { + try? FileManager.default.removeItem(at: file) + } + } + + /// Removes all decoy videos and their decoy thumbnails. Called when the + /// poison pill is removed so the gallery stops showing the decoy badge. + func removeAllDecoyVideos() { + let decoyFiles = storage.getDecoyVideoFiles() for file in decoyFiles { try? FileManager.default.removeItem(at: file) } + deleteAllDecoyVideoThumbnails() } - + // MARK: - Update Operations - + /// Updates an existing image with new image data while preserving EXIF metadata func updateImage(_ photoDef: PhotoDef, newImageData: Data) async throws { // Load existing image to extract EXIF metadata let existingImageData = try await decryptJpg(photoDef) - let existingMetadata = extractEXIFMetadata(from: existingImageData) - + let existingMetadata = ImageProcessing.extractEXIFMetadata(from: existingImageData) + // Process the new image with preserved EXIF metadata - let processedData = try processImageWithEXIFMetadata( + let processedData = try ImageProcessing.processImageWithEXIFMetadata( imageData: newImageData, preservedEXIFMetadata: existingMetadata, filename: photoDef.photoName ) - + // Save the updated image try await encryptionScheme.encryptToFile(plain: processedData, targetFile: photoDef.photoFile) - + // Clear thumbnail cache to force regeneration thumbnailCache.clearThumbnail(photoDef.photoName) - let thumbnailFile = getThumbnailFile(photoDef) + let thumbnailFile = storage.getThumbnailFile(photoDef) try? FileManager.default.removeItem(at: thumbnailFile) } - - // MARK: - Private Helper Methods - - /// Extracts EXIF metadata from image data - private func extractEXIFMetadata(from imageData: Data) -> [String: Any] { - var exifMetadata: [String: Any] = [:] - - guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil), - let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] else { - return exifMetadata - } - - // Preserve orientation - if let orientation = imageProperties[kCGImagePropertyOrientation as String] as? Int { - exifMetadata[kCGImagePropertyOrientation as String] = orientation - } - - // Preserve EXIF data - if let exifDict = imageProperties[kCGImagePropertyExifDictionary as String] as? [String: Any] { - exifMetadata[kCGImagePropertyExifDictionary as String] = exifDict - } - - // Preserve TIFF data - if let tiffDict = imageProperties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] { - exifMetadata[kCGImagePropertyTIFFDictionary as String] = tiffDict - } - - // Preserve GPS data - if let gpsDict = imageProperties[kCGImagePropertyGPSDictionary as String] as? [String: Any] { - exifMetadata[kCGImagePropertyGPSDictionary as String] = gpsDict - } - - return exifMetadata - } - - /// Processes image data while preserving EXIF metadata - private func processImageWithEXIFMetadata( - imageData: Data, - preservedEXIFMetadata: [String: Any], - filename: String - ) throws -> Data { - guard let image = UIImage(data: imageData) else { - throw ImageRepositoryError.invalidImageData - } - - // Convert to JPEG with quality - guard let jpegData = image.jpegData(compressionQuality: 0.9) else { - throw ImageRepositoryError.compressionFailed - } - - // If no EXIF metadata to preserve, return the processed image - if preservedEXIFMetadata.isEmpty { - return jpegData - } - - // Create image destination to write JPEG with preserved metadata - let mutableData = NSMutableData(data: jpegData) - let type = UTType.jpeg.identifier as CFString - guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, 1, nil) else { - return jpegData - } - - // Create image source from processed JPEG - guard let source = CGImageSourceCreateWithData(jpegData as CFData, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - return jpegData - } - - // Add the image with preserved EXIF metadata - CGImageDestinationAddImage(destination, cgImage, preservedEXIFMetadata as CFDictionary) - - if CGImageDestinationFinalize(destination) { - return mutableData as Data - } - - // Fallback to original processed image if metadata preservation fails - return jpegData - } - + // MARK: - Helper Methods struct PhotoMetaData { - let name: String let resolution: Size let dateTaken: Date let location: GpsCoordinates? @@ -634,26 +661,22 @@ public class SecureImageRepository { // MARK: - Main API - @MainActor func getPhotoMetaData(_ photoDef: PhotoDef) async throws -> PhotoMetaData { - let name = photoDef.photoName let dateTaken: Date = photoDef.dateTaken() ?? Date(timeIntervalSince1970: 0) - + var orientation: TiffOrientation? = nil var coords: GpsCoordinates? = nil var size = Size(width: 0, height: 0) - - // Your decryptor should return the JPG bytes as Data - let jpgBytes = try await decryptJpg(photoDef: photoDef) - - if let md = readImageMetadata(fromJPEGData: jpgBytes) { + + let jpgBytes = try await decryptJpg(photoDef) + + if let md = ImageProcessing.readImageMetadata(fromJPEGData: jpgBytes) { orientation = md.orientation coords = md.gps size = Size(width: md.width ?? 0, height: md.height ?? 0) } - + return PhotoMetaData( - name: name, resolution: size, dateTaken: dateTaken, location: coords, @@ -661,50 +684,89 @@ public class SecureImageRepository { ) } - // MARK: - Decrypt (stub; replace with your implementation) + func getVideoMetaData(_ videoDef: VideoDef) async throws -> VideoMetaData { + // 1. File size from disk. + let attributes = try FileManager.default.attributesOfItem(atPath: videoDef.videoFile.path) + let fileSize = (attributes[.size] as? Int64) ?? 0 - func decryptJpg(photoDef: PhotoDef) async throws -> Data { - return try await encryptionScheme.decryptFile(photoDef.photoFile) - } + // 2. Build an AVAsset that reads the (decrypted) bytes. For .secv we route + // through EncryptedVideoDataSource so no temp plaintext is written. + let asset: AVURLAsset + if videoDef.isEncrypted { + let key = SymmetricKey(data: try await encryptionScheme.getDerivedKey()) + guard let encrypted = AVAsset.makeEncryptedVideoAsset( + with: videoDef.videoFile, encryptionKey: key) else { + throw SECVError.decryptionFailed + } + asset = encrypted + } else { + asset = AVURLAsset(url: videoDef.videoFile) + } - // MARK: - ImageIO helpers + // 3. Load duration + common metadata. + let (commonMetadata, duration) = try await asset.load(.commonMetadata, .duration) - private func readImageMetadata(fromJPEGData data: Data) -> ParsedImageMetadata? { - guard let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } - let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] - - let pixelWidth = (props?[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue - let pixelHeight = (props?[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue - - var orientation: TiffOrientation? = nil - if let tiff = props?[kCGImagePropertyTIFFDictionary] as? [CFString: Any], - let ori = (tiff[kCGImagePropertyTIFFOrientation] as? NSNumber)?.intValue, - let mapped = TiffOrientation(rawValue: ori) { - orientation = mapped - } else if let ori = (props?[kCGImagePropertyOrientation] as? NSNumber)?.intValue, - let mapped = TiffOrientation(rawValue: ori) { - // Some writers put orientation at the top level - orientation = mapped - } - - var gpsCoords: GpsCoordinates? = nil - if let gps = props?[kCGImagePropertyGPSDictionary] as? [CFString: Any] { - if let lat = gps[kCGImagePropertyGPSLatitude] as? NSNumber, - let latRef = gps[kCGImagePropertyGPSLatitudeRef] as? String, - let lon = gps[kCGImagePropertyGPSLongitude] as? NSNumber, - let lonRef = gps[kCGImagePropertyGPSLongitudeRef] as? String { - let latSign = (latRef.uppercased() == "S") ? -1.0 : 1.0 - let lonSign = (lonRef.uppercased() == "W") ? -1.0 : 1.0 - gpsCoords = GpsCoordinates(latitude: lat.doubleValue * latSign, - longitude: lon.doubleValue * lonSign) + // 4. Location (ISO 6709), if embedded. + var location: GpsCoordinates? + if let item = AVMetadataItem.metadataItems( + from: commonMetadata, filteredByIdentifier: .commonIdentifierLocation).first, + let iso = try await item.load(.stringValue) { + location = AVMetadataItemFactory.parseISO6709(iso) + } + + // 5. Capture date: embedded creation date, else filename, else epoch. + var dateTaken = Date(timeIntervalSince1970: 0) + var dateSource: DateSource = .filename + if let item = AVMetadataItem.metadataItems( + from: commonMetadata, filteredByIdentifier: .commonIdentifierCreationDate).first { + let dateValue = try await item.load(.dateValue) + let stringValue = try await item.load(.stringValue) + if let embedded = dateValue ?? stringValue.flatMap({ AVMetadataItemFactory.iso8601Date(from: $0) }) { + dateTaken = embedded + dateSource = .embedded + } else if let fromName = videoDef.dateTaken() { + dateTaken = fromName + dateSource = .filename + } + } else if let fromName = videoDef.dateTaken() { + dateTaken = fromName + dateSource = .filename + } + + // 6. Technical fields from the first video track (best-effort: any + // missing/zero field becomes nil and renders as "—"). + var resolution = Size(width: 0, height: 0) + var orientation: TiffOrientation? + var codec: String? + var frameRate: Double? + var bitrate: Int? + + if let track = try await asset.loadTracks(withMediaType: .video).first { + let (naturalSize, transform, nominalFrameRate, dataRate, formats) = try await track.load( + .naturalSize, .preferredTransform, .nominalFrameRate, .estimatedDataRate, .formatDescriptions) + + resolution = Size(width: Int(abs(naturalSize.width)), height: Int(abs(naturalSize.height))) + orientation = AVMetadataItemFactory.videoOrientation(fromTransform: transform) + if nominalFrameRate > 0 { frameRate = Double(nominalFrameRate) } + if dataRate > 0 { bitrate = Int(dataRate) } + if let format = formats.first { + codec = AVMetadataItemFactory.codecString( + fromMediaSubType: CMFormatDescriptionGetMediaSubType(format)) } } - - return ParsedImageMetadata( - width: pixelWidth, - height: pixelHeight, + + let seconds = duration.seconds + return VideoMetaData( + resolution: resolution, + duration: seconds.isFinite ? seconds : 0, + dateTaken: dateTaken, + dateTakenSource: dateSource, + location: location, orientation: orientation, - gps: gpsCoords + codec: codec, + frameRate: frameRate, + bitrate: bitrate, + fileSize: fileSize ) } } @@ -714,30 +776,4 @@ public class SecureImageRepository { enum ImageRepositoryError: Error { case compressionFailed case invalidImageData - case encryptionFailed - case decryptionFailed -} - -// MARK: - Metadata - -private struct ParsedImageMetadata { - let width: Int? - let height: Int? - let orientation: TiffOrientation? - let gps: GpsCoordinates? -} - -struct Size { - let width: Int - let height: Int -} - -enum TiffOrientation: Int { - case up = 1, upMirrored = 2, down = 3, downMirrored = 4 - case leftMirrored = 5, right = 6, rightMirrored = 7, left = 8 -} - -struct GpsCoordinates { - let latitude: Double - let longitude: Double } diff --git a/SnapSafe/Data/SecureImage/ThumbnailCache.swift b/SnapSafe/Data/SecureImage/ThumbnailCache.swift index f6921bc..d0623be 100644 --- a/SnapSafe/Data/SecureImage/ThumbnailCache.swift +++ b/SnapSafe/Data/SecureImage/ThumbnailCache.swift @@ -7,8 +7,7 @@ import UIKit -@MainActor -class ThumbnailCache { +final class ThumbnailCache { private var cache = NSCache() init() { @@ -26,7 +25,23 @@ class ThumbnailCache { func evictThumbnail(_ photoDef: PhotoDef) { cache.removeObject(forKey: photoDef.photoName as NSString) } - + + // MARK: - Video thumbnails (keyed by video name, prefixed to avoid collisions) + + private func videoKey(_ name: String) -> NSString { "video:\(name)" as NSString } + + func getVideoThumbnail(_ name: String) -> UIImage? { + return cache.object(forKey: videoKey(name)) + } + + func putVideoThumbnail(_ name: String, _ image: UIImage) { + cache.setObject(image, forKey: videoKey(name)) + } + + func evictVideoThumbnail(_ name: String) { + cache.removeObject(forKey: videoKey(name)) + } + func clearThumbnail(_ photoName: String) { cache.removeObject(forKey: photoName as NSString) } @@ -35,3 +50,10 @@ class ThumbnailCache { cache.removeAllObjects() } } + +// MARK: - Sendable + +// NSCache is documented thread-safe; direct access to the backing `cache` +// only happens through this class's own methods, so @unchecked Sendable is +// legitimate here. +extension ThumbnailCache: @unchecked Sendable {} diff --git a/SnapSafe/Data/UseCases/AddDecoyPhotoUseCase.swift b/SnapSafe/Data/UseCases/AddDecoyPhotoUseCase.swift index 3a52453..f9db37e 100644 --- a/SnapSafe/Data/UseCases/AddDecoyPhotoUseCase.swift +++ b/SnapSafe/Data/UseCases/AddDecoyPhotoUseCase.swift @@ -26,6 +26,12 @@ final class AddDecoyPhotoUseCase: @unchecked Sendable { } func addDecoyPhoto(photoDef: PhotoDef) async -> Bool { + // Enforce the decoy limit BEFORE any cryptographic work: at the limit we + // must not read the plaintext poison-pill PIN or derive a key. + guard await imageRepository.numDecoys() < SecureImageRepository.maxDecoyItems else { + return false + } + guard let ppp = await pinRepository.getHashedPoisonPillPin(), let plain = await pinRepository.getPlainPoisonPillPin() diff --git a/SnapSafe/Data/UseCases/AddDecoyVideoUseCase.swift b/SnapSafe/Data/UseCases/AddDecoyVideoUseCase.swift new file mode 100644 index 0000000..63f66ca --- /dev/null +++ b/SnapSafe/Data/UseCases/AddDecoyVideoUseCase.swift @@ -0,0 +1,54 @@ +// +// AddDecoyVideoUseCase.swift +// SnapSafe +// + +import Foundation +import FactoryKit +import Logging + + +/// Marks a video as a decoy. Mirrors `AddDecoyPhotoUseCase`: it derives the +/// poison-pill key and asks the repository to re-encrypt the video with it so +/// the decoy survives (and stays playable) after the poison pill is activated. +final class AddDecoyVideoUseCase: @unchecked Sendable { + private let pinRepository: PinRepository + private let encryptionScheme: EncryptionScheme + private let imageRepository: SecureImageRepository + + init( + pinRepository: PinRepository, + encryptionScheme: EncryptionScheme, + imageRepository: SecureImageRepository + ) { + self.pinRepository = pinRepository + self.encryptionScheme = encryptionScheme + self.imageRepository = imageRepository + } + + func addDecoyVideo(videoDef: VideoDef) async -> Bool { + // Enforce the decoy limit BEFORE any cryptographic work: at the limit we + // must not read the plaintext poison-pill PIN or derive a key. The limit + // is shared across photos and videos (numDecoys() counts both). + guard await imageRepository.numDecoys() < SecureImageRepository.maxDecoyItems else { + return false + } + + guard + let ppp = await pinRepository.getHashedPoisonPillPin(), + let plain = await pinRepository.getPlainPoisonPillPin() + else { + return false + } + + let keyBytes: Data + do { + keyBytes = try await encryptionScheme.deriveKey(plainPin: plain, hashedPin: ppp) + } catch { + Logger.security.error("Failed to derive key for Poison Pill setting decoy video: \(error)") + return false + } + + return await imageRepository.addDecoyVideoWithKey(videoDef, keyData: keyBytes) + } +} diff --git a/SnapSafe/Data/UseCases/AuthorizePinUseCase.swift b/SnapSafe/Data/UseCases/AuthorizePinUseCase.swift index abb7dfc..171dc70 100644 --- a/SnapSafe/Data/UseCases/AuthorizePinUseCase.swift +++ b/SnapSafe/Data/UseCases/AuthorizePinUseCase.swift @@ -6,11 +6,11 @@ // -public final class AuthorizePinUseCase: @unchecked Sendable { +final class AuthorizePinUseCase: @unchecked Sendable { private let authRepository: AuthorizationRepository private let pinRepository: PinRepository - public init( + init( authRepository: AuthorizationRepository, pinRepository: PinRepository, ) { @@ -21,7 +21,7 @@ public final class AuthorizePinUseCase: @unchecked Sendable { /// Authorizes user by verifying the PIN and updates the authorization state if successful. /// - Parameter pin: The PIN entered by the user /// - Returns: The stored `HashedPin` if the PIN is correct; otherwise `nil`. - public func authorizePin(_ pin: String) async -> HashedPin? { + func authorizePin(_ pin: String) async -> HashedPin? { let hashedPin = await pinRepository.getHashedPin() let isValid = await pinRepository.verifySecurityPin(pin) @@ -29,7 +29,7 @@ public final class AuthorizePinUseCase: @unchecked Sendable { return nil } - self.authRepository.authorizeSession() + await self.authRepository.authorizeSession() await authRepository.resetFailedAttempts() return hashedPin } diff --git a/SnapSafe/Data/UseCases/CreatePinUseCase.swift b/SnapSafe/Data/UseCases/CreatePinUseCase.swift index cce11cf..7d52b46 100644 --- a/SnapSafe/Data/UseCases/CreatePinUseCase.swift +++ b/SnapSafe/Data/UseCases/CreatePinUseCase.swift @@ -8,14 +8,14 @@ import Logging -public final class CreatePinUseCase: @unchecked Sendable { +final class CreatePinUseCase: @unchecked Sendable { private let authorizationRepository: AuthorizationRepository private let encryptionScheme: EncryptionScheme private let pinRepository: PinRepository private let settingsDataSource: SettingsDataSource private let authorizePinUseCase: AuthorizePinUseCase - public init( + init( authorizationRepository: AuthorizationRepository, encryptionScheme: EncryptionScheme, pinRepository: PinRepository, @@ -32,7 +32,7 @@ public final class CreatePinUseCase: @unchecked Sendable { /// Creates a PIN, immediately authorizes it, and on success: /// 1) creates the key, 2) derives & caches encryption key, 3) marks intro complete. /// - Returns: `true` on success, `false` otherwise. - public func createPin(_ pin: String) async -> Bool { + func createPin(_ pin: String) async -> Bool { do { await pinRepository.setAppPin(pin) @@ -44,7 +44,6 @@ public final class CreatePinUseCase: @unchecked Sendable { await settingsDataSource.setIntroCompleted(true) return true } catch { - // Log the error for debugging purposes Logger.security.error("Failed to create PIN", metadata: [ "error": .string(String(describing: error)) ]) diff --git a/SnapSafe/Data/UseCases/CreatePoisonPillUseCase.swift b/SnapSafe/Data/UseCases/CreatePoisonPillUseCase.swift index bc39691..818def8 100644 --- a/SnapSafe/Data/UseCases/CreatePoisonPillUseCase.swift +++ b/SnapSafe/Data/UseCases/CreatePoisonPillUseCase.swift @@ -5,6 +5,8 @@ // Created by Adam Brown on 9/11/25. // +import Logging + final class CreatePoisonPillUseCase: @unchecked Sendable { private let pinRepository: PinRepository private let encryptionScheme: EncryptionScheme @@ -16,12 +18,18 @@ final class CreatePoisonPillUseCase: @unchecked Sendable { func createPin(pppin: String) async -> Bool { await pinRepository.setPoisonPillPin(pppin) - guard let hashedPPPin = await pinRepository.getHashedPoisonPillPin() - else { - fatalError("Failed to retrieve hashed pin") + guard let hashedPPPin = await pinRepository.getHashedPoisonPillPin() else { + Logger.security.error("Failed to retrieve hashed poison pill pin") + return false + } + + do { + try await encryptionScheme.createKey(plainPin: pppin, hashedPin: hashedPPPin) + } catch { + Logger.security.error("Failed to create poison pill key: \(error)") + return false } - try! await encryptionScheme.createKey(plainPin: pppin, hashedPin: hashedPPPin) - + return true } } diff --git a/SnapSafe/Data/UseCases/InvalidateSessionUseCase.swift b/SnapSafe/Data/UseCases/InvalidateSessionUseCase.swift index e6378f4..54e15c5 100644 --- a/SnapSafe/Data/UseCases/InvalidateSessionUseCase.swift +++ b/SnapSafe/Data/UseCases/InvalidateSessionUseCase.swift @@ -21,8 +21,8 @@ final class InvalidateSessionUseCase { self.authManager = authManager } - func invalidateSession() { - imageRepository.evictKey() + func invalidateSession() async { + await imageRepository.evictKey() imageRepository.thumbnailCache.clear() authManager.revokeAuthorization() } diff --git a/SnapSafe/Data/UseCases/PinStrengthCheckUseCase.swift b/SnapSafe/Data/UseCases/PinStrengthCheckUseCase.swift index 506bb85..8cfd39a 100644 --- a/SnapSafe/Data/UseCases/PinStrengthCheckUseCase.swift +++ b/SnapSafe/Data/UseCases/PinStrengthCheckUseCase.swift @@ -8,36 +8,64 @@ import Foundation final class PinStrengthCheckUseCase { - func isPinStrongEnough(_ pin: String) -> Bool { + func isPinStrongEnough(_ pin: String, isAlphanumeric: Bool = false) -> Bool { + isAlphanumeric ? isAlphanumericPinStrongEnough(pin) : isNumericPinStrongEnough(pin) + } + + private func isNumericPinStrongEnough(_ pin: String) -> Bool { // Check if PIN is at least 4 digits long and contains only digits guard pin.count >= 4, pin.allSatisfy({ $0.isNumber }) else { return false } - + // Check if all digits are the same (e.g., "1111") if let firstChar = pin.first, pin.allSatisfy({ $0 == firstChar }) { return false } - + // Check if PIN is a sequence (ascending or descending) let digits = pin.compactMap { $0.wholeNumberValue } let isAscendingSequence = zip(digits, digits.dropFirst()).allSatisfy { $1 - $0 == 1 } let isDescendingSequence = zip(digits, digits.dropFirst()).allSatisfy { $1 - $0 == -1 } - if isAscendingSequence || isDescendingSequence { return false } - + // Check against blacklist - if Self.blackList.contains(pin) { + if Self.numericBlackList.contains(pin) { + return false + } + + return true + } + + private func isAlphanumericPinStrongEnough(_ pin: String) -> Bool { + guard pin.count >= 4 else { return false } + + // Check if all characters are the same (e.g., "aaaa") + if let firstChar = pin.first, pin.allSatisfy({ $0 == firstChar }) { + return false + } + + // Check against common-password blacklist (case-insensitive) + if Self.alphanumericBlackList.contains(pin.lowercased()) { return false } - + return true } - - private static let blackList: [String] = [ + + private static let numericBlackList: [String] = [ "1212", "6969", ] + + private static let alphanumericBlackList: [String] = [ + "password", + "letmein", + "abc123", + "abcd1234", + "qwerty", + "iloveyou", + ] } diff --git a/SnapSafe/Data/UseCases/PrepareForSharingUseCase.swift b/SnapSafe/Data/UseCases/PrepareForSharingUseCase.swift index 0d8a969..5cdd4ff 100644 --- a/SnapSafe/Data/UseCases/PrepareForSharingUseCase.swift +++ b/SnapSafe/Data/UseCases/PrepareForSharingUseCase.swift @@ -5,7 +5,7 @@ // Created by Adam Brown on 9/9/25. // -public final class PrepareForSharingUseCase { +final class PrepareForSharingUseCase { // Creates a temporary file for sharing with a UUID filename func preparePhotoForSharing(imageData: Data) throws -> URL { // Get temporary directory diff --git a/SnapSafe/Data/UseCases/RemoveDecoyPhotoUseCase.swift b/SnapSafe/Data/UseCases/RemoveDecoyPhotoUseCase.swift index 9fe1f0b..eaeccee 100644 --- a/SnapSafe/Data/UseCases/RemoveDecoyPhotoUseCase.swift +++ b/SnapSafe/Data/UseCases/RemoveDecoyPhotoUseCase.swift @@ -15,7 +15,7 @@ import Logging purely a pass-through. But we have an AddDecoyPhotoUseCase, so I like the obvious symetry of having both. */ -final class RemoveDecoyPhotoUseCase { +final class RemoveDecoyPhotoUseCase: @unchecked Sendable { private let imageRepository: SecureImageRepository init( @@ -24,7 +24,7 @@ final class RemoveDecoyPhotoUseCase { self.imageRepository = imageRepository } - @MainActor func removeDecoyPhoto(_ photoDef: PhotoDef) -> Bool { - return self.imageRepository.removeDecoyPhoto(photoDef) + func removeDecoyPhoto(_ photoDef: PhotoDef) async -> Bool { + return await self.imageRepository.removeDecoyPhoto(photoDef) } } diff --git a/SnapSafe/Data/UseCases/RemovePoisonPillIUseCase.swift b/SnapSafe/Data/UseCases/RemovePoisonPillIUseCase.swift index 1e31762..bf998a5 100644 --- a/SnapSafe/Data/UseCases/RemovePoisonPillIUseCase.swift +++ b/SnapSafe/Data/UseCases/RemovePoisonPillIUseCase.swift @@ -19,5 +19,6 @@ final class RemovePoisonPillUseCase: @unchecked Sendable { func removePoisonPill() async { await pinRepository.removePoisonPillPin() await imageRepository.removeAllDecoyPhotos() + await imageRepository.removeAllDecoyVideos() } } diff --git a/SnapSafe/Data/UseCases/SecurityResetUseCase.swift b/SnapSafe/Data/UseCases/SecurityResetUseCase.swift index 1150531..fef56e2 100644 --- a/SnapSafe/Data/UseCases/SecurityResetUseCase.swift +++ b/SnapSafe/Data/UseCases/SecurityResetUseCase.swift @@ -12,6 +12,30 @@ final class SecurityResetUseCase: @unchecked Sendable { private let authRepo: AuthorizationRepository private let imageRepository: SecureImageRepository private let encryptionScheme: EncryptionScheme + + /// Delete any stranded unencrypted .mov files left from interrupted recordings. + /// Call this on app startup to ensure no plaintext video data persists. + static func cleanupStrandedTempVideos() { + let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let videosDir = appSupportPath.appendingPathComponent("videos") + + guard FileManager.default.fileExists(atPath: videosDir.path) else { return } + + do { + let files = try FileManager.default.contentsOfDirectory(at: videosDir, includingPropertiesForKeys: nil) + let movFiles = files.filter { $0.pathExtension.lowercased() == "mov" } + for file in movFiles { + try FileManager.default.removeItem(at: file) + Logger.security.info("Deleted stranded temp video", metadata: [ + "file": .string(file.lastPathComponent) + ]) + } + } catch { + Logger.security.error("Failed to clean up temp videos", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } init( authManager: AuthorizationRepository, @@ -27,6 +51,29 @@ final class SecurityResetUseCase: @unchecked Sendable { await authRepo.securityFailureReset() await imageRepository.securityFailureReset() await encryptionScheme.securityFailureReset() + deleteAllVideos() Logger.security.info("Security Reset Complete!") } + + /// Delete all video files (both temp .mov and encrypted .secv). + private func deleteAllVideos() { + let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let videosDir = appSupportPath.appendingPathComponent("videos") + + guard FileManager.default.fileExists(atPath: videosDir.path) else { return } + + do { + let files = try FileManager.default.contentsOfDirectory(at: videosDir, includingPropertiesForKeys: nil) + for file in files { + try FileManager.default.removeItem(at: file) + } + Logger.security.info("Deleted all video files during security reset", metadata: [ + "count": .stringConvertible(files.count) + ]) + } catch { + Logger.security.error("Failed to delete video files during security reset", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } } diff --git a/SnapSafe/Data/UseCases/VerifyPinUseCase.swift b/SnapSafe/Data/UseCases/VerifyPinUseCase.swift index 8003538..7b3c05d 100644 --- a/SnapSafe/Data/UseCases/VerifyPinUseCase.swift +++ b/SnapSafe/Data/UseCases/VerifyPinUseCase.swift @@ -8,14 +8,29 @@ import Foundation import Logging -public final class VerifyPinUseCase: @unchecked Sendable { +/// Outcome of a PIN verification attempt. +/// +/// `failure` is reserved for transient, retryable errors (e.g. I/O while reading +/// the wrapped DEK, or `errSecInteractionNotAllowed` if the device locks mid-flow). +/// It is intentionally distinct from `invalidPin` so the UI can offer a retry +/// without burning a failed-attempt against the user. +enum PinVerificationResult: Sendable { + case success + /// The PIN did not match. Carries the authoritative post-increment failed + /// attempt count from the repository (the single writer), so the caller + /// never re-derives or re-writes it from stale local state (M1). + case invalidPin(failedAttempts: Int) + case failure(Error) +} + +final class VerifyPinUseCase: @unchecked Sendable { private let authRepo: AuthorizationRepository private let imageRepo: SecureImageRepository private let pinRepository: PinRepository private let encryptionScheme: EncryptionScheme private let authorizePinUseCase: AuthorizePinUseCase - public init( + init( authRepository: AuthorizationRepository, imageRepository: SecureImageRepository, pinRepository: PinRepository, @@ -29,39 +44,53 @@ public final class VerifyPinUseCase: @unchecked Sendable { self.authorizePinUseCase = authorizePinUseCase } - /// Verifies a PIN and handles poison pill activation if detected - /// - Parameter pin: The PIN to verify - /// - Returns: `true` if PIN verification succeeded, `false` otherwise - public func verifyPin(_ pin: String) async -> Bool { - // Check for poison pill PIN first - let hasPoison = await pinRepository.hasPoisonPillPin() - let isPoison = await pinRepository.verifyPoisonPillPin(pin) - - // Check for poison pill PIN first - if hasPoison && isPoison { + /// Verifies a PIN and handles poison pill activation if detected. + /// - Parameter pin: The PIN to verify. + /// - Returns: `.success` when the PIN is correct and the key is derived and cached, + /// `.invalidPin` when the PIN does not match, or `.failure(error)` when a + /// transient/retryable error occurs (e.g. key derivation I/O or hardware + /// transient failure). Callers should surface `.failure` as a retryable error + /// without counting it as a failed attempt. + func verifyPin(_ pin: String) async -> PinVerificationResult { + // Check for poison pill PIN first. Short-circuit on hasPoisonPillPin + // so we don't run a second Argon2 verification each attempt and don't + // leak a timing oracle revealing whether a poison pill is configured. + if await pinRepository.hasPoisonPillPin(), + await pinRepository.verifyPoisonPillPin(pin) { Logger.security.warning("Poison pill PIN detected - activating poison pill mode") - + // Get the old hashed PIN before activating poison pill let oldHashedPin = await pinRepository.getHashedPin() - + // Activate poison pill across all components encryptionScheme.activatePoisonPill(oldPin: oldHashedPin) await imageRepo.activatePoisonPill() await pinRepository.activatePoisonPill() - + Logger.security.info("Poison pill mode activated successfully") } - + // Attempt regular PIN authorization let hashedPin = await authorizePinUseCase.authorizePin(pin) guard let hashedPin else { - _ = await authRepo.incrementFailedAttempts() + // Single writer: the repository owns the counter and the backoff + // timestamp/baseline. Return the authoritative new count so the + // caller displays it without writing it a second time (M1). + let failedAttempts = await authRepo.incrementFailedAttempts() Logger.security.warning("PIN verification failed - invalid PIN provided") - return false + return .invalidPin(failedAttempts: failedAttempts) + } + + do { + try await encryptionScheme.deriveAndCacheKey(plainPin: pin, hashedPin: hashedPin) + } catch { + Logger.security.error("Key derivation failed after valid PIN", metadata: [ + "error": .string(String(describing: error)) + ]) + return .failure(error) } - try! await encryptionScheme.deriveAndCacheKey(plainPin: pin, hashedPin: hashedPin) Logger.security.info("PIN verification successful") - return true + return .success } } diff --git a/SnapSafe/Data/UserData/FileBasedSettingsDataSource.swift b/SnapSafe/Data/UserData/FileBasedSettingsDataSource.swift index 407a2df..baf99dd 100644 --- a/SnapSafe/Data/UserData/FileBasedSettingsDataSource.swift +++ b/SnapSafe/Data/UserData/FileBasedSettingsDataSource.swift @@ -16,17 +16,18 @@ private struct SettingsData: Codable { var sanitizeFileName: Bool var sanitizeMetadata: Bool var sessionTimeoutMs: Int64 - var cipherKey: String var cipheredPin: String? var failedPinAttempts: Int var lastFailedAttempt: Int64 var poisonPillPlain: String? var poisonPillHashed: String? + // Optional so existing settings files (without the key) decode cleanly; nil means false. + var alphanumericPinEnabled: Bool? } // MARK: - File-based Implementation -public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked Sendable { +final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked Sendable { // MARK: - Combine subjects (reflect stored values) private nonisolated(unsafe) let hasCompletedIntroSubject: CurrentValueSubject private nonisolated(unsafe) let sanitizeFileNameSubject: CurrentValueSubject @@ -34,14 +35,15 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S private nonisolated(unsafe) let sessionTimeoutSubject: CurrentValueSubject // MARK: - Public publishers - public var hasCompletedIntro: AnyPublisher { hasCompletedIntroSubject.eraseToAnyPublisher() } - public var sanitizeFileName: AnyPublisher { sanitizeFileNameSubject.eraseToAnyPublisher() } - public var sanitizeMetadata: AnyPublisher { sanitizeMetadataSubject.eraseToAnyPublisher() } - public var sessionTimeout: AnyPublisher { sessionTimeoutSubject.eraseToAnyPublisher() } + var hasCompletedIntro: AnyPublisher { hasCompletedIntroSubject.eraseToAnyPublisher() } + var hasCompletedIntroValue: Bool { hasCompletedIntroSubject.value } + var sanitizeFileName: AnyPublisher { sanitizeFileNameSubject.eraseToAnyPublisher() } + var sanitizeMetadata: AnyPublisher { sanitizeMetadataSubject.eraseToAnyPublisher() } + var sessionTimeout: AnyPublisher { sessionTimeoutSubject.eraseToAnyPublisher() } // MARK: - Declared defaults (exposed by protocol) - public let sanitizeFileNameDefault: Bool - public let sanitizeMetadataDefault: Bool + let sanitizeFileNameDefault: Bool + let sanitizeMetadataDefault: Bool // MARK: - Thread Safety private let queue = DispatchQueue(label: "com.snapsafe.settings", qos: .utility, attributes: .concurrent) @@ -58,7 +60,7 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S // MARK: - Init /// - Parameter sanitizeFileNameDefault: Default value for sanitize file name setting /// - Parameter sanitizeMetadataDefault: Default value for sanitize metadata setting - public init( + init( sanitizeFileNameDefault: Bool = Defaults.sanitizeFileName, sanitizeMetadataDefault: Bool = Defaults.sanitizeMetadata, fileURL: URL? = nil @@ -73,14 +75,14 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S sanitizeFileName: sanitizeFileNameDefault, sanitizeMetadata: sanitizeMetadataDefault, sessionTimeoutMs: Defaults.sessionTimeoutMs, - cipherKey: Defaults.cipherKey, cipheredPin: nil, failedPinAttempts: 0, lastFailedAttempt: 0, poisonPillPlain: nil, - poisonPillHashed: nil + poisonPillHashed: nil, + alphanumericPinEnabled: nil ) - + // Load existing settings or use defaults self._settingsData = Self.loadSettingsFromFile(url: self.fileURL, defaults: defaultSettings) Logger.storage.debug("FileBasedSettingsDataSource initialized", metadata: [ @@ -158,7 +160,11 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S do { let data = try self.jsonEncoder.encode(self._settingsData) - try data.write(to: self.fileURL, options: .atomic) + // Complete file protection: the settings file holds the + // reversible poison-pill PIN ciphertext and the PIN hashes, so it + // must be unreadable while the device is locked, not merely + // excluded from backup (H2, Option D). + try data.write(to: self.fileURL, options: [.atomic, .completeFileProtection]) Logger.storage.debug("Settings saved to file", metadata: [ "fileURL": .string(self.fileURL.path), "fileSize": .stringConvertible(data.count) @@ -188,34 +194,39 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } // MARK: - Keys & PIN - public func getCipherKey() async -> String { - return readProperty(\.cipherKey) - } - - public func getCipheredPin() async -> String? { + func getCipheredPin() async -> String? { return readProperty(\.cipheredPin) } - public func setIntroCompleted(_ completed: Bool) async { + func setIntroCompleted(_ completed: Bool) async { writeProperty(\.hasCompletedIntro, value: completed) await MainActor.run { hasCompletedIntroSubject.send(completed) } } - public func setAppPin(cipheredPin: String) async { + func setAppPin(cipheredPin: String) async { writeProperty(\.cipheredPin, value: cipheredPin) } + // MARK: - Alphanumeric PIN preference + func getAlphanumericPinEnabled() async -> Bool { + return readProperty(\.alphanumericPinEnabled) ?? false + } + + func setAlphanumericPinEnabled(_ enabled: Bool) async { + writeProperty(\.alphanumericPinEnabled, value: enabled) + } + // MARK: - Sanitize prefs - public func setSanitizeFileName(_ sanitize: Bool) async { + func setSanitizeFileName(_ sanitize: Bool) async { writeProperty(\.sanitizeFileName, value: sanitize) await MainActor.run { sanitizeFileNameSubject.send(sanitize) } } - public func setSanitizeMetadata(_ sanitize: Bool) async { + func setSanitizeMetadata(_ sanitize: Bool) async { writeProperty(\.sanitizeMetadata, value: sanitize) await MainActor.run { sanitizeMetadataSubject.send(sanitize) @@ -223,24 +234,42 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } // MARK: - Failed PIN attempts - public func getFailedPinAttempts() async -> Int { + func getFailedPinAttempts() async -> Int { return readProperty(\.failedPinAttempts) } - public func setFailedPinAttempts(_ count: Int) async { + func setFailedPinAttempts(_ count: Int) async { writeProperty(\.failedPinAttempts, value: count) } - public func getLastFailedAttemptTimestamp() async -> Int64 { + func incrementFailedPinAttempts() async -> Int { + // Read-modify-write inside a single barrier block so the increment is atomic + // with respect to every other access on the queue; concurrent callers can't + // observe the same starting value and lose an increment. + await withCheckedContinuation { continuation in + queue.async(flags: .barrier) { [weak self] in + guard let self else { + continuation.resume(returning: 0) + return + } + let newCount = self._settingsData.failedPinAttempts + 1 + self._settingsData.failedPinAttempts = newCount + self.saveSettingsToFile() + continuation.resume(returning: newCount) + } + } + } + + func getLastFailedAttemptTimestamp() async -> Int64 { return readProperty(\.lastFailedAttempt) } - public func setLastFailedAttemptTimestamp(_ timestamp: Int64) async { + func setLastFailedAttemptTimestamp(_ timestamp: Int64) async { writeProperty(\.lastFailedAttempt, value: timestamp) } // MARK: - Security reset - public func securityFailureReset() async { + func securityFailureReset() async { return await withCheckedContinuation { continuation in queue.async(flags: .barrier) { [weak self] in guard let self = self else { @@ -258,14 +287,14 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S sanitizeFileName: self.sanitizeFileNameDefault, sanitizeMetadata: self.sanitizeMetadataDefault, sessionTimeoutMs: self._settingsData.sessionTimeoutMs, // Preserve session timeout - cipherKey: Defaults.cipherKey, cipheredPin: nil, failedPinAttempts: 0, lastFailedAttempt: 0, poisonPillPlain: nil, - poisonPillHashed: nil + poisonPillHashed: nil, + alphanumericPinEnabled: nil ) - + self.saveSettingsToFile() // Emit changes on main actor @@ -281,11 +310,11 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } // MARK: - Session timeout - public func getSessionTimeout() async -> Int64 { + func getSessionTimeout() async -> Int64 { return readProperty(\.sessionTimeoutMs) } - public func setSessionTimeout(_ timeoutMs: Int64) async { + func setSessionTimeout(_ timeoutMs: Int64) async { writeProperty(\.sessionTimeoutMs, value: timeoutMs) await MainActor.run { sessionTimeoutSubject.send(timeoutMs) @@ -293,7 +322,7 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } // MARK: - Poison Pill - public func setPoisonPillPin(cipheredHashedPin: String, cipheredPlainPin: String) async { + func setPoisonPillPin(cipheredHashedPin: String, cipheredPlainPin: String) async { return await withCheckedContinuation { continuation in queue.async(flags: .barrier) { [weak self] in guard let self = self else { @@ -309,19 +338,19 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } } - public func getPlainPoisonPillPin() async -> String? { + func getPlainPoisonPillPin() async -> String? { return readProperty(\.poisonPillPlain) } - public func getHashedPoisonPillPin() async -> String? { + func getHashedPoisonPillPin() async -> String? { return readProperty(\.poisonPillHashed) } - public func activatePoisonPill(ciphered: String) async { + func activatePoisonPill(ciphered: String) async { writeProperty(\.cipheredPin, value: ciphered) } - public func removePoisonPillPin() async { + func removePoisonPillPin() async { return await withCheckedContinuation { continuation in queue.async(flags: .barrier) { [weak self] in guard let self = self else { @@ -337,28 +366,8 @@ public final class FileBasedSettingsDataSource: SettingsDataSource, @unchecked S } } - public func isPinCiphered() async -> Bool { + // periphery:ignore + func isPinCiphered() async -> Bool { return readProperty(\.cipheredPin) != nil } } - -// MARK: - Testing Support - -extension FileBasedSettingsDataSource { - /// Creates an instance that uses a temporary file for testing - public static func inMemoryForTesting( - sanitizeFileNameDefault: Bool = Defaults.sanitizeFileName, - sanitizeMetadataDefault: Bool = Defaults.sanitizeMetadata - ) -> FileBasedSettingsDataSource { - // This will create a temporary file that gets cleaned up automatically - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent("test-settings-\(UUID().uuidString).json") - - let instance = FileBasedSettingsDataSource( - sanitizeFileNameDefault: sanitizeFileNameDefault, sanitizeMetadataDefault: sanitizeMetadataDefault, - fileURL: tempURL - ) - - return instance - } -} diff --git a/SnapSafe/Data/UserData/SettingsDataSource.swift b/SnapSafe/Data/UserData/SettingsDataSource.swift index 4f6bd8a..ebe3b96 100644 --- a/SnapSafe/Data/UserData/SettingsDataSource.swift +++ b/SnapSafe/Data/UserData/SettingsDataSource.swift @@ -11,19 +11,24 @@ import Mockable @Mockable -public protocol SettingsDataSource: Sendable { +protocol SettingsDataSource: Sendable { // MARK: - Intro state /// Check if the user has completed the introduction var hasCompletedIntro: AnyPublisher { get } + /// Synchronous read of the current intro-completion state (no scheduler hop). + /// Use this to seed view-model state before the Combine pipeline has a chance to deliver. + var hasCompletedIntroValue: Bool { get } // MARK: - Sanitize file name /// Get the sanitized file name preference var sanitizeFileName: AnyPublisher { get } + // periphery:ignore var sanitizeFileNameDefault: Bool { get } // MARK: - Sanitize metadata /// Get the sanitized metadata preference var sanitizeMetadata: AnyPublisher { get } + // periphery:ignore var sanitizeMetadataDefault: Bool { get } // MARK: - Session timeout @@ -31,7 +36,6 @@ public protocol SettingsDataSource: Sendable { var sessionTimeout: AnyPublisher { get } // MARK: - Keys & PIN - func getCipherKey() async -> String func getCipheredPin() async -> String? /// Set the introduction completion status @@ -40,6 +44,15 @@ public protocol SettingsDataSource: Sendable { /// Set the app PIN func setAppPin(cipheredPin: String) async + // MARK: - Alphanumeric PIN preference + /// Whether PINs (app PIN and poison pill) accept letters as well as digits. + /// This is a single global preference — both PINs share one type, matching the + /// Android implementation. Defaults to `false` (numeric) when never set. + func getAlphanumericPinEnabled() async -> Bool + + /// Set the global alphanumeric-PIN preference. + func setAlphanumericPinEnabled(_ enabled: Bool) async + /// Set the sanitize file name preference func setSanitizeFileName(_ sanitize: Bool) async @@ -53,6 +66,12 @@ public protocol SettingsDataSource: Sendable { /// Set the failed PIN attempts count func setFailedPinAttempts(_ count: Int) async + /// Atomically increment the failed PIN attempts count and return the new value. + /// The read-modify-write happens inside the data source's own critical section so + /// concurrent callers can't lose an increment — the auth lockout counter must + /// never be undercounted. + func incrementFailedPinAttempts() async -> Int + /// Get the current timestamp of the last failed PIN attempt func getLastFailedAttemptTimestamp() async -> Int64 @@ -86,5 +105,6 @@ public protocol SettingsDataSource: Sendable { /// Remove the Poison Pill PIN func removePoisonPillPin() async + // periphery:ignore func isPinCiphered() async -> Bool } diff --git a/SnapSafe/Data/UserData/UserDefaultsSettingsDataSource.swift b/SnapSafe/Data/UserData/UserDefaultsSettingsDataSource.swift index cd20a37..b16662b 100644 --- a/SnapSafe/Data/UserData/UserDefaultsSettingsDataSource.swift +++ b/SnapSafe/Data/UserData/UserDefaultsSettingsDataSource.swift @@ -15,26 +15,25 @@ private enum PrefKeys: String { case sanitizeFileName = "prefs.sanitizeFileName" // Bool case sanitizeMetadata = "prefs.sanitizeMetadata" // Bool case sessionTimeoutMs = "prefs.sessionTimeoutMs" // Int64 (stored as Int) - case cipherKey = "prefs.cipherKey" // String case cipheredPin = "prefs.cipheredPin" // String? case failedPinAttempts = "prefs.failedPinAttempts" // Int case lastFailedAttempt = "prefs.lastFailedAttempt" // Int64 (stored as Int) case poisonPillPlain = "prefs.poisonPillPlain" // String? case poisonPillHashed = "prefs.poisonPillHashed" // String? + case alphanumericPin = "prefs.alphanumericPinEnabled" // Bool? (nil when never set) } // MARK: - Defaults (adjust to taste) -public enum Defaults { - public static let sanitizeFileName: Bool = true - public static let sanitizeMetadata: Bool = true - public static let sessionTimeoutMs: Int64 = 60_000 - public static let cipherKey: String = "stub-cipher-key" // In production, move to Keychain +enum Defaults { + static let sanitizeFileName: Bool = true + static let sanitizeMetadata: Bool = true + static let sessionTimeoutMs: Int64 = 300_000 } // MARK: - UserDefaults Impl -public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecked Sendable { +final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecked Sendable { // MARK: - Combine subjects (reflect stored values) private nonisolated(unsafe) let hasCompletedIntroSubject: CurrentValueSubject private nonisolated(unsafe) let sanitizeFileNameSubject: CurrentValueSubject @@ -43,25 +42,28 @@ public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecke // MARK: - Public publishers - public var hasCompletedIntro: AnyPublisher { hasCompletedIntroSubject.eraseToAnyPublisher() } - public var sanitizeFileName: AnyPublisher { sanitizeFileNameSubject.eraseToAnyPublisher() } - public var sanitizeMetadata: AnyPublisher { sanitizeMetadataSubject.eraseToAnyPublisher() } - public var sessionTimeout: AnyPublisher { sessionTimeoutSubject.eraseToAnyPublisher() } + var hasCompletedIntro: AnyPublisher { hasCompletedIntroSubject.eraseToAnyPublisher() } + var hasCompletedIntroValue: Bool { hasCompletedIntroSubject.value } + var sanitizeFileName: AnyPublisher { sanitizeFileNameSubject.eraseToAnyPublisher() } + var sanitizeMetadata: AnyPublisher { sanitizeMetadataSubject.eraseToAnyPublisher() } + var sessionTimeout: AnyPublisher { sessionTimeoutSubject.eraseToAnyPublisher() } // MARK: - Declared defaults (exposed by protocol) - public let sanitizeFileNameDefault: Bool - public let sanitizeMetadataDefault: Bool + let sanitizeFileNameDefault: Bool + let sanitizeMetadataDefault: Bool // MARK: - Storage + JSON private nonisolated(unsafe) let defaults: UserDefaults - private let jsonDecoder = JSONDecoder() - private let jsonEncoder = jsonEncoderFactory() + + /// Serializes the failed-attempts read-modify-write. UserDefaults has no atomic + /// increment, so without this lock concurrent callers could lose an increment. + private let failedAttemptsLock = NSLock() // MARK: - Init /// - Parameter userDefaults: UserDefaults instance to use. Defaults to `.standard`. /// - Parameter sanitizeFileNameDefault: Default value for sanitize file name setting /// - Parameter sanitizeMetadataDefault: Default value for sanitize metadata setting - public init( + init( userDefaults: UserDefaults = .standard, sanitizeFileNameDefault: Bool = Defaults.sanitizeFileName, sanitizeMetadataDefault: Bool = Defaults.sanitizeMetadata @@ -79,9 +81,6 @@ public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecke if store.object(forKey: PrefKeys.sanitizeMetadata.rawValue) == nil { store.set(sanitizeMetadataDefault, forKey: PrefKeys.sanitizeMetadata.rawValue) } - if store.string(forKey: PrefKeys.cipherKey.rawValue) == nil { - store.set(Defaults.cipherKey, forKey: PrefKeys.cipherKey.rawValue) - } if store.object(forKey: PrefKeys.sessionTimeoutMs.rawValue) == nil { store.set(Int(Defaults.sessionTimeoutMs), forKey: PrefKeys.sessionTimeoutMs.rawValue) } @@ -102,56 +101,74 @@ public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecke } // MARK: - Keys & PIN - public func getCipherKey() async -> String { - defaults.string(forKey: PrefKeys.cipherKey.rawValue) ?? Defaults.cipherKey - } - - public func getCipheredPin() async -> String? { + func getCipheredPin() async -> String? { defaults.string(forKey: PrefKeys.cipheredPin.rawValue) } - public func setIntroCompleted(_ completed: Bool) async { + func setIntroCompleted(_ completed: Bool) async { defaults.set(completed, forKey: PrefKeys.hasCompletedIntro.rawValue) hasCompletedIntroSubject.send(completed) } /// Accepts the ciphered PIN and a JSON string for `SchemeConfig`. /// If JSON parsing fails, we still set the PIN but leave the previous scheme config untouched. - public func setAppPin(cipheredPin: String) async { + func setAppPin(cipheredPin: String) async { defaults.set(cipheredPin, forKey: PrefKeys.cipheredPin.rawValue) } + // MARK: - Alphanumeric PIN preference + func getAlphanumericPinEnabled() async -> Bool { + if defaults.object(forKey: PrefKeys.alphanumericPin.rawValue) == nil { return false } + return defaults.bool(forKey: PrefKeys.alphanumericPin.rawValue) + } + + func setAlphanumericPinEnabled(_ enabled: Bool) async { + defaults.set(enabled, forKey: PrefKeys.alphanumericPin.rawValue) + } + // MARK: - Sanitize prefs - public func setSanitizeFileName(_ sanitize: Bool) async { + func setSanitizeFileName(_ sanitize: Bool) async { defaults.set(sanitize, forKey: PrefKeys.sanitizeFileName.rawValue) sanitizeFileNameSubject.send(sanitize) } - public func setSanitizeMetadata(_ sanitize: Bool) async { + func setSanitizeMetadata(_ sanitize: Bool) async { defaults.set(sanitize, forKey: PrefKeys.sanitizeMetadata.rawValue) sanitizeMetadataSubject.send(sanitize) } // MARK: - Failed PIN attempts - public func getFailedPinAttempts() async -> Int { + func getFailedPinAttempts() async -> Int { let v = defaults.object(forKey: PrefKeys.failedPinAttempts.rawValue) return (v as? Int) ?? 0 } - public func setFailedPinAttempts(_ count: Int) async { + func setFailedPinAttempts(_ count: Int) async { defaults.set(count, forKey: PrefKeys.failedPinAttempts.rawValue) } - public func getLastFailedAttemptTimestamp() async -> Int64 { + func incrementFailedPinAttempts() async -> Int { + // Guard the read-modify-write so concurrent callers can't read the same + // starting value and lose an increment. `withLock` keeps the critical section + // synchronous (no suspension while holding the lock). + failedAttemptsLock.withLock { + let current = (defaults.object(forKey: PrefKeys.failedPinAttempts.rawValue) as? Int) ?? 0 + let newCount = current + 1 + defaults.set(newCount, forKey: PrefKeys.failedPinAttempts.rawValue) + return newCount + } + } + + func getLastFailedAttemptTimestamp() async -> Int64 { Int64(defaults.integer(forKey: PrefKeys.lastFailedAttempt.rawValue)) } - public func setLastFailedAttemptTimestamp(_ timestamp: Int64) async { + func setLastFailedAttemptTimestamp(_ timestamp: Int64) async { defaults.set(Int(timestamp), forKey: PrefKeys.lastFailedAttempt.rawValue) } // MARK: - Security reset - public func securityFailureReset() async { + func securityFailureReset() async { // Remove sensitive and preference keys [ PrefKeys.cipheredPin, @@ -161,7 +178,8 @@ public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecke PrefKeys.lastFailedAttempt, PrefKeys.hasCompletedIntro, PrefKeys.sanitizeFileName, - PrefKeys.sanitizeMetadata + PrefKeys.sanitizeMetadata, + PrefKeys.alphanumericPin ].forEach { defaults.removeObject(forKey: $0.rawValue) } // Restore defaults for observed prefs @@ -176,40 +194,40 @@ public final class UserDefaultsSettingsDataSource: SettingsDataSource, @unchecke } // MARK: - Session timeout (direct access) - public func getSessionTimeout() async -> Int64 { + func getSessionTimeout() async -> Int64 { Int64(defaults.integer(forKey: PrefKeys.sessionTimeoutMs.rawValue)) } - public func setSessionTimeout(_ timeoutMs: Int64) async { + func setSessionTimeout(_ timeoutMs: Int64) async { defaults.set(Int(timeoutMs), forKey: PrefKeys.sessionTimeoutMs.rawValue) sessionTimeoutSubject.send(timeoutMs) } // MARK: - Poison Pill - public func setPoisonPillPin(cipheredHashedPin: String, cipheredPlainPin: String) async { + func setPoisonPillPin(cipheredHashedPin: String, cipheredPlainPin: String) async { defaults.set(cipheredHashedPin, forKey: PrefKeys.poisonPillHashed.rawValue) defaults.set(cipheredPlainPin, forKey: PrefKeys.poisonPillPlain.rawValue) } - public func getPlainPoisonPillPin() async -> String? { + func getPlainPoisonPillPin() async -> String? { defaults.string(forKey: PrefKeys.poisonPillPlain.rawValue) } - public func getHashedPoisonPillPin() async -> String? { + func getHashedPoisonPillPin() async -> String? { defaults.string(forKey: PrefKeys.poisonPillHashed.rawValue) } - public func activatePoisonPill(ciphered: String) async { + func activatePoisonPill(ciphered: String) async { // Replace the main PIN with the poison-pill ciphered value defaults.set(ciphered, forKey: PrefKeys.cipheredPin.rawValue) } - public func removePoisonPillPin() async { + func removePoisonPillPin() async { defaults.removeObject(forKey: PrefKeys.poisonPillPlain.rawValue) defaults.removeObject(forKey: PrefKeys.poisonPillHashed.rawValue) } - public func isPinCiphered() async -> Bool { + func isPinCiphered() async -> Bool { defaults.string(forKey: PrefKeys.cipheredPin.rawValue) != nil } } diff --git a/SnapSafe/DeveloperToolsView.swift b/SnapSafe/DeveloperToolsView.swift new file mode 100644 index 0000000..6ac4ef8 --- /dev/null +++ b/SnapSafe/DeveloperToolsView.swift @@ -0,0 +1,65 @@ +// Development/testing tool — compiled in Debug builds only, never ships. +#if DEBUG +// +// DeveloperToolsView.swift +// SnapSafe +// +// Created by Assistant on 5/25/26. +// + +import SwiftUI + +/// A development view for accessing testing tools during development +/// This should be removed or gated in production builds +// periphery:ignore +@available(iOS 18.0, *) +struct DeveloperToolsView: View { + @EnvironmentObject private var nav: AppNavigationState + + var body: some View { + NavigationStack { + List { + Section("Testing Tools") { + Button(action: { + nav.navigate(to: .videoExportTest) + }) { + HStack { + Image(systemName: "video.badge.waveform") + .foregroundStyle(.blue) + + VStack(alignment: .leading) { + Text("Video Export Test") + .font(.headline) + Text("Test video creation and export functionality on simulator") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + .font(.caption) + } + } + .buttonStyle(.plain) + } + + Section(footer: Text("These tools are for development and testing purposes only. They will not be available in production builds.")) { + EmptyView() + } + } + .navigationTitle("Developer Tools") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back") { + nav.navigateBack() + } + } + } + } + } +} +#endif diff --git a/SnapSafe/PrivacyInfo.xcprivacy b/SnapSafe/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..886e2ff --- /dev/null +++ b/SnapSafe/PrivacyInfo.xcprivacy @@ -0,0 +1,38 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/SnapSafe/RunVideoExportTests.swift b/SnapSafe/RunVideoExportTests.swift new file mode 100644 index 0000000..6a95304 --- /dev/null +++ b/SnapSafe/RunVideoExportTests.swift @@ -0,0 +1,53 @@ +// Development/testing tool — compiled in Debug builds only, never ships. +#if DEBUG +// +// RunVideoExportTests.swift +// SnapSafe +// +// Created by Assistant on 5/25/26. +// + +import Foundation + +/// Simple script to run video export tests from Xcode console +/// Run this in Xcode console: po runVideoExportTests() +// periphery:ignore +@available(iOS 18.0, *) +func runVideoExportTests() async { + print("🎬 Starting Video Export Tests for Simulator...") + print("=====================================") + + #if DEBUG + let results = await VideoExportValidator.runAllTests() + + print("🎯 All tests completed!") + print("=====================================") + + for result in results { + let status = result.success ? "PASS" : "FAIL" + let emoji = result.success ? "✅" : "❌" + print("\(emoji) \(result.testName): \(status)") + if !result.success { + print(" Error: \(result.message)") + } + } + + let passCount = results.filter { $0.success }.count + let totalCount = results.count + + print("\n📊 Test Summary: \(passCount)/\(totalCount) tests passed") + print("\n💡 To access interactive tests, long-press the settings gear icon (⚙️) in the camera view") + #else + print("❌ Tests are only available in DEBUG builds") + #endif +} + +/// Quick access function that can be called from anywhere in debug builds +#if DEBUG +// periphery:ignore +@available(iOS 18.0, *) +func quickVideoTest() async { + await runVideoExportTests() +} +#endif +#endif diff --git a/SnapSafe/ScreenCaptureManager.swift b/SnapSafe/ScreenCaptureManager.swift index c46718b..e1dd498 100644 --- a/SnapSafe/ScreenCaptureManager.swift +++ b/SnapSafe/ScreenCaptureManager.swift @@ -140,23 +140,23 @@ struct ScreenRecordingBlockerView: View { // Warning icon Image(systemName: "record.circle") .font(.system(size: 80)) - .foregroundColor(.red) + .foregroundStyle(.red) .padding(.top, 60) // Warning message Text("Screen Recording Detected") .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) + .foregroundStyle(.white) Text("For privacy and security reasons, screen recording is not allowed in SnapSafe.") .font(.system(size: 16)) - .foregroundColor(.gray) + .foregroundStyle(.gray) .multilineTextAlignment(.center) .padding(.horizontal, 40) Text("Please stop recording to continue using the app.") .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.top, 20) Spacer() diff --git a/SnapSafe/Screens/About/AboutView.swift b/SnapSafe/Screens/About/AboutView.swift index 4a85bc2..0f4288f 100644 --- a/SnapSafe/Screens/About/AboutView.swift +++ b/SnapSafe/Screens/About/AboutView.swift @@ -17,7 +17,7 @@ struct AboutView: View { VStack(spacing: 16) { Image(systemName: "camera.circle.fill") .font(.system(size: 80)) - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("SnapSafe") .font(.largeTitle) @@ -25,11 +25,11 @@ struct AboutView: View { Text("Secure Photo Storage") .font(.headline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text("Version \(viewModel.appVersion)") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 20) @@ -39,7 +39,7 @@ struct AboutView: View { Section("About") { Text("SnapSafe is a privacy-focused camera app designed to protect your sensitive photos with strong encryption and secure storage.") .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .padding(.vertical, 4) Button("SnapSafe.org") { @@ -47,13 +47,13 @@ struct AboutView: View { UIApplication.shared.open(url) } } - .foregroundColor(.blue) + .foregroundStyle(.blue) } Section("Community") { Text("Come engage with our community, discover more Free and Open Source Software!") .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .padding(.vertical, 4) Button("Join our Discord") { @@ -61,13 +61,13 @@ struct AboutView: View { UIApplication.shared.open(url) } } - .foregroundColor(.blue) + .foregroundStyle(.blue) } Section("Open Source") { Text("SnapSafe is an open source project. View the source code on GitHub:") .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .padding(.vertical, 4) Button("GitHub") { @@ -75,13 +75,13 @@ struct AboutView: View { UIApplication.shared.open(url) } } - .foregroundColor(.blue) + .foregroundStyle(.blue) } Section("Privacy") { Text("SnapSafe stores all data locally on your device. No data is transmitted to external servers.") .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .padding(.vertical, 4) Button("Privacy Policy") { @@ -89,13 +89,13 @@ struct AboutView: View { UIApplication.shared.open(url) } } - .foregroundColor(.blue) + .foregroundStyle(.blue) } Section("Report Bugs") { Text("Found a bug? Report it on GitHub:") .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .padding(.vertical, 4) Button("Report Bug") { @@ -103,7 +103,7 @@ struct AboutView: View { UIApplication.shared.open(url) } } - .foregroundColor(.blue) + .foregroundStyle(.blue) } } .navigationTitle("About") @@ -112,7 +112,7 @@ struct AboutView: View { } #Preview { - NavigationView { + NavigationStack { AboutView() } } diff --git a/SnapSafe/Screens/AppNavigation.swift b/SnapSafe/Screens/AppNavigation.swift index e3c0cb4..dbde6cc 100644 --- a/SnapSafe/Screens/AppNavigation.swift +++ b/SnapSafe/Screens/AppNavigation.swift @@ -16,10 +16,13 @@ enum AppDestination: Hashable { case pinSetup case pinVerification case camera - case photoDetail(allPhotos: [PhotoDef], initialIndex: Int) + case photoDetail(allMedia: [GalleryMediaItem], initialIndex: Int) case photoInfo(PhotoDef) + case videoInfo(VideoDef) case photoObfuscation(PhotoDef) case poisonPillSetupWizard + case videoPlayer(VideoDef, Data?) + case videoExportTest // For testing video export on simulator } // MARK: - Navigation State @@ -58,10 +61,6 @@ final class AppNavigationState: ObservableObject { presentedSheet = destination } - func presentFullScreenCover(_ destination: AppDestination) { - presentedFullScreenCover = destination - } - func dismissSheet() { presentedSheet = nil } @@ -89,8 +88,11 @@ extension AppDestination: Identifiable { case .camera: return "camera" case .photoDetail(_, let initialIndex): return "photoDetail_\(initialIndex)" case .photoInfo(let photoDef): return "photoInfo_\(photoDef.photoName)" + case .videoInfo(let videoDef): return "videoInfo_\(videoDef.videoName)" case .photoObfuscation(let photoDef): return "photoObfuscation_\(photoDef.photoName)" case .poisonPillSetupWizard: return "poisonPillSetupWizard" + case .videoPlayer(let videoDef, _): return "videoPlayer_\(videoDef.videoName)" + case .videoExportTest: return "videoExportTest" } } } diff --git a/SnapSafe/Screens/Camera/CamControl.swift b/SnapSafe/Screens/Camera/CamControl.swift index 4f79d49..db9597d 100644 --- a/SnapSafe/Screens/Camera/CamControl.swift +++ b/SnapSafe/Screens/Camera/CamControl.swift @@ -4,191 +4,3 @@ // // Created by Bill Booth on 5/3/25. // - -import AVFoundation -import CoreGraphics -import CoreLocation -import ImageIO -import Photos -import UIKit -import FactoryKit -import Logging - -@MainActor -class SecureCameraController: UIViewController, AVCapturePhotoCaptureDelegate { - private var captureSession: AVCaptureSession! - private var photoOutput: AVCapturePhotoOutput! - private var previewLayer: AVCaptureVideoPreviewLayer! - - @Injected(\.locationRepository) - private var locationRepository: LocationRepository - - @Injected(\.secureImageRepository) - private var secureImageRepository: SecureImageRepository - - @Injected(\.clock) - private var clock: Clock - - override func viewDidLoad() { - super.viewDidLoad() - setupCamera() - } - - private func setupCamera() { - captureSession = AVCaptureSession() - - guard let backCamera = AVCaptureDevice.default(for: .video) else { - // Handle camera unavailable - return - } - - do { - let input = try AVCaptureDeviceInput(device: backCamera) - captureSession.addInput(input) - - photoOutput = AVCapturePhotoOutput() - captureSession.addOutput(photoOutput) - - // Set quality prioritization to maximum quality over speed - photoOutput.maxPhotoQualityPrioritization = .quality - Logger.camera.debug("Set photo quality prioritization to maximum quality") - - // Prepare for zero shutter lag - if photoOutput.isFastCapturePrioritizationSupported { - Logger.camera.debug("Fast capture prioritization is supported, preparing zero shutter lag pipeline") - let zslSettings = AVCapturePhotoSettings() - photoOutput.setPreparedPhotoSettingsArray([zslSettings]) - } - - previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - previewLayer.frame = view.bounds - previewLayer.videoGravity = .resizeAspectFill - view.layer.addSublayer(previewLayer) - - // Configure camera device for optimal quality - try backCamera.lockForConfiguration() - - // Enable subject area change monitoring - backCamera.isSubjectAreaChangeMonitoringEnabled = true - Logger.camera.debug("Enabled subject area change monitoring") - - if backCamera.isExposureModeSupported(.continuousAutoExposure) { - // Use a faster shutter speed (1/500 sec) for sharper images - let fastShutter = CMTime(value: 1, timescale: 500) // 1/500 sec - // Set ISO to a reasonable value (or max if needed) - let iso = min(backCamera.activeFormat.maxISO, 400) - - // Only set custom exposure if we're in good lighting conditions - if backCamera.exposureDuration.seconds < 0.1 { // Current exposure is faster than 1/10s - Logger.camera.debug("Setting shutter-priority exposure", metadata: [ - "shutter": .string("1/500s"), - "iso": .stringConvertible(iso) - ]) - backCamera.setExposureModeCustom(duration: fastShutter, iso: iso) { _ in - // After setting custom exposure, lock it to prevent auto changes - try? backCamera.lockForConfiguration() - backCamera.exposureMode = .locked - backCamera.unlockForConfiguration() - } - } - } - - backCamera.unlockForConfiguration() - - // Add observer for subject area changes - NotificationCenter.default.addObserver( - self, - selector: #selector(subjectAreaDidChange), - name: .AVCaptureDeviceSubjectAreaDidChange, - object: backCamera - ) - - captureSession.startRunning() - } catch { - // Handle camera setup error - } - } - - // Handle subject area changes by refocusing - @objc private func subjectAreaDidChange(notification: NSNotification) { - guard let device = notification.object as? AVCaptureDevice else { return } - - // Refocus to center or last known focus point - let focusPoint = CGPoint(x: 0.5, y: 0.5) // Default to center - - do { - try device.lockForConfiguration() - - // Set focus point and mode if supported - if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(.autoFocus) { - device.focusPointOfInterest = focusPoint - device.focusMode = .autoFocus - Logger.camera.debug("Refocusing after subject area change") - } - - // Set exposure point if supported - if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.continuousAutoExposure) { - device.exposurePointOfInterest = focusPoint - device.exposureMode = .continuousAutoExposure - } - - device.unlockForConfiguration() - } catch { - Logger.camera.error("Error refocusing", metadata: [ - "error": .string(error.localizedDescription) - ]) - } - } - - func capturePhoto() { - let settings: AVCapturePhotoSettings - settings = AVCapturePhotoSettings() - settings.photoQualityPrioritization = .quality - photoOutput.capturePhoto(with: settings, delegate: self) - } - - nonisolated func photoOutput(_: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - guard error == nil else { - // Handle photo capture error - Logger.camera.error("Error capturing photo", metadata: [ - "error": .string(error!.localizedDescription) - ]) - return - } - } - - nonisolated func photoOutput(_: AVCapturePhotoOutput, didFinishCapturingDeferredPhotoProxy proxy: AVCaptureDeferredPhotoProxy?, error: Error?) { - guard error == nil else { - Logger.camera.error("Error with deferred photo", metadata: [ - "error": .string(error!.localizedDescription) - ]) - return - } - } - - private func extractMetadata(from imageData: Data) -> [String: Any]? { - guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else { - return nil - } - - guard let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] else { - return nil - } - - return metadata - } - - private func processEXIFData(_ metadata: [String: Any]) -> [String: Any] { - let processedMetadata = metadata - - // Extract GPS data if available - if metadata[String(kCGImagePropertyGPSDictionary)] is [String: Any] { - // Process GPS data as needed - // Store separate from image for security - } - - // Process other EXIF data as needed - - return processedMetadata - } -} diff --git a/SnapSafe/Screens/Camera/CameraContainerView.swift b/SnapSafe/Screens/Camera/CameraContainerView.swift index c2b8f30..1b69dcc 100644 --- a/SnapSafe/Screens/Camera/CameraContainerView.swift +++ b/SnapSafe/Screens/Camera/CameraContainerView.swift @@ -14,206 +14,455 @@ import Logging struct CameraContainerView: View { @StateObject private var cameraModel = CameraViewModel() @EnvironmentObject private var nav: AppNavigationState - - // Local camera UI state + @State private var isShutterAnimating = false - @State private var deviceOrientation = UIDevice.current.orientation @State private var showZoomSlider = false @State private var isPinching = false - + @State private var shutterFeedbackTrigger = 0 + @State private var zoomResetTrigger = 0 + @State private var focusExclusionRects: [CGRect] = [] + @StateObject private var orientation = OrientationObserver() + + /// Shared coordinate space spanning the full-screen preview, used to report + /// overlaid-control frames to the focus gesture as exclusion zones. + private static let cameraSpaceName = "cameraFocusSpace" + var body: some View { + // The camera UI is locked to portrait. The preview is full-bleed and the + // controls live in a single column that IGNORES the safe area and pads + // itself by the STABLE window safe-area insets. This makes the layout + // immune to the phantom safe-area inset iOS injects when the device is + // physically rotated while the interface stays locked to portrait — which + // previously shoved the bottom controls up into the preview. The glyphs + // still rotate in place (iOS Camera style); capture orientation is + // handled independently by the capture pipeline. + GeometryReader { proxy in ZStack { - CameraView(cameraModel: cameraModel, onPinchStarted: { + CameraView(cameraModel: cameraModel, focusExclusionRects: focusExclusionRects, onPinchStarted: { isPinching = true - withAnimation { - showZoomSlider = true - } + withAnimation { showZoomSlider = true } }, onPinchChanged: { isPinching = true }, onPinchEnded: { isPinching = false }) - .edgesIgnoringSafeArea(.all) + .ignoresSafeArea() - // Shutter animation overlay if isShutterAnimating { Color.black .opacity(0.8) - .edgesIgnoringSafeArea(.all) + .ignoresSafeArea() .transition(.opacity) } - // Camera controls overlay - VStack { - // Top control bar with flash toggle and camera switch - HStack { - // Camera switch button - Button(action: { - Task { - let newPosition: AVCaptureDevice.Position = (cameraModel.cameraPosition == .back) ? .front : .back - await cameraModel.switchCamera(to: newPosition) - } - }) { - Image(systemName: "arrow.triangle.2.circlepath.camera") - .font(.system(size: 20)) - .foregroundColor(.white) - .padding(12) - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - } - .padding(.top, 16) - .padding(.leading, 16) - - Spacer() - - // Flash control button - disabled for front camera - Button(action: { - Logger.ui.info("Flash button tapped, current mode: \(cameraModel.flashMode)") - cameraModel.toggleFlashMode() - }) { - Image(systemName: cameraModel.flashIcon) - .font(.system(size: 20)) - .foregroundColor(cameraModel.cameraPosition == .front ? .gray : .white) - .padding(12) - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - } - .disabled(cameraModel.cameraPosition == .front) - .buttonStyle(PlainButtonStyle()) - .padding(.top, 16) - .padding(.trailing, 16) + // Saving HUD: stays up for a minimum duration (see the view + // model's videoSavingGate) and fades in/out, so a short clip's + // near-instant encryption reads as a confirmation, not a flash. + if cameraModel.isSavingVideo { + VStack(spacing: 12) { + ProgressView(value: min(cameraModel.encryptionProgress, 1.0), total: 1.0) + .progressViewStyle(LinearProgressViewStyle(tint: .white)) + .frame(width: 200) + Text("Encrypting & saving…") + .font(.caption) + .foregroundStyle(.white) } + .padding(20) + .background(Color.black.opacity(0.7)) + .clipShape(.rect(cornerRadius: 12)) + // Rotate in place with the device, like the other camera + // chrome — the interface stays locked to portrait. + .rotatesWithDevice(orientation) + .transition(.opacity) + } + + if cameraModel.isRecording { + recordingIndicatorOverlay + } + + controlsColumn(letterbox: letterboxHeight(in: proxy.size)) + .environment(\.colorScheme, .dark) + } + .coordinateSpace(.named(Self.cameraSpaceName)) + .onPreferenceChange(FocusExclusionPreferenceKey.self) { rects in + focusExclusionRects = rects + } + .animation(.easeInOut(duration: 0.1), value: isShutterAnimating) + .animation(.easeInOut(duration: 0.25), value: cameraModel.isSavingVideo) + .animation(.easeInOut(duration: 0.25), value: cameraModel.isRecording) + } + .ignoresSafeArea() + .supportedOrientations(.portrait) + .onAppear { + Task { + await cameraModel.checkAndSetupCamera() + } + } + } + + /// Height of the black band above/below the preview container in a + /// full-screen layout of `size`. The control rows pad themselves by this + /// so they sit just inside the image instead of straddling its edges. + private func letterboxHeight(in size: CGSize) -> CGFloat { + let container = CameraPreviewLayout.containerSize( + for: size, + aspectRatio: cameraModel.captureAspectRatio + ) + return max(0, (size.height - container.height) / 2) + } + + /// The window's safe-area insets, which stay stable while the interface is + /// locked to portrait. SwiftUI's environment safe area is unreliable here: + /// iOS injects a phantom bottom inset on physical rotation even though the + /// interface never leaves portrait, and that is exactly what we must ignore. + private var stableSafeInsets: EdgeInsets { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let window = scenes.first(where: { $0.activationState == .foregroundActive })?.keyWindow + ?? scenes.first?.keyWindow + let i = window?.safeAreaInsets ?? UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0) + return EdgeInsets(top: i.top, leading: i.left, bottom: i.bottom, trailing: i.right) + } + + /// A transparent reporter to drop in a control's `.background`. It measures + /// the control's frame in the shared camera coordinate space (optionally + /// expanded by `expand` for a more liberal margin) and publishes it as a + /// focus-exclusion zone, so tap-to-focus won't fire on that control. + private func focusExclusionReporter(expand: CGFloat = 0, active: Bool = true) -> some View { + GeometryReader { proxy in + Color.clear + .preference( + key: FocusExclusionPreferenceKey.self, + value: active + ? [proxy.frame(in: .named(Self.cameraSpaceName)).insetBy(dx: -expand, dy: -expand)] + : [] + ) + } + } + + // MARK: - Controls overlay (top bar + zoom + mode picker) + private func controlsColumn(letterbox: CGFloat) -> some View { + VStack(spacing: 0) { + // Top controls + HStack { + cameraSwitchButton Spacer() + flashButton + } + // Carve this control bar out of the tap-to-focus area so the focus + // gesture on the preview beneath doesn't swallow the buttons' taps + // (the capture-area container can span the full width on large + // screens, putting these controls inside it). + .background(focusExclusionReporter(expand: 8, active: !cameraModel.isRecording)) + .hideWhileRecording(cameraModel.isRecording) - // Zoom slider (full control) - if showZoomSlider { - ZoomSliderView(cameraModel: cameraModel, isVisible: $showZoomSlider, isPinching: isPinching) - .padding(.horizontal, 16) - .padding(.bottom, 10) - } else { - // Simple zoom level indicator - ZStack { - Capsule() - .fill(Color.black.opacity(0.6)) - .frame(width: 80, height: 30) - - Text(String(format: "%.1fx", cameraModel.zoomFactor)) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) - } - .opacity(cameraModel.zoomFactor != 1.0 ? 1.0 : 0.0) - .animation(.easeInOut, value: cameraModel.zoomFactor) + Spacer(minLength: 0) + + if showZoomSlider { + ZoomSliderView(cameraModel: cameraModel, isVisible: $showZoomSlider, isPinching: isPinching) .padding(.bottom, 10) - .rotationEffect(Utils.getRotationAngle()) - .animation(.easeInOut, value: deviceOrientation) - .gesture( - // Use exclusively to properly distinguish single vs double tap - TapGesture(count: 2) - .onEnded { _ in - Logger.camera.debug("Double tap detected on zoom indicator") - handleDoubleTabZoomIndicator() - } - .exclusively(before: - TapGesture(count: 1) - .onEnded { _ in - Logger.camera.debug("Single tap detected on zoom indicator") - withAnimation { - showZoomSlider = true - } - } - ) - ) - } + .hideWhileRecording(cameraModel.isRecording) + } else { + zoomCapsule + .frame(height: orientation.orientation.isLandscape ? 96 : 44) + .hideWhileRecording(cameraModel.isRecording) + } - HStack { - Button(action: { - nav.navigate(to:.gallery) - }) { - ZStack { - Image(systemName: "photo.on.rectangle") - .font(.system(size: 24)) - .foregroundColor(cameraModel.isSavingPhoto ? .gray : .white) - .padding() - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - if cameraModel.isSavingPhoto { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .scaleEffect(0.7) - } - } - } - .disabled(cameraModel.isSavingPhoto) - .padding() + // Photo / video toggle + modePicker + .background(focusExclusionReporter(expand: 20, active: !cameraModel.isRecording)) + .padding(.bottom, 12) + .hideWhileRecording(cameraModel.isRecording) + + // Capture bar (gallery / shutter / settings). Only the shutter + // stays visible while recording; gallery + settings fade out so the + // viewfinder reads as a single-purpose stop-recording surface. + HStack { + galleryButton + .hideWhileRecording(cameraModel.isRecording) + Spacer() + captureButton + Spacer() + settingsButton + .hideWhileRecording(cameraModel.isRecording) + } + .frame(maxWidth: 420) + // Same focus-exclusion treatment as the top bar so these taps reach + // the buttons instead of the focus gesture underneath. + .background(focusExclusionReporter(expand: 8)) + } + .padding(.horizontal, 16) + // Track the preview rect: the rows clear the letterbox bands and sit + // inside the image with an even margin (falling back to the stable + // safe area on screens where the preview fills the height). + .padding(.top, max(stableSafeInsets.top + 8, letterbox + 12)) + .padding(.bottom, max(stableSafeInsets.bottom + 4, letterbox + 12)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Individual controls + + private var cameraSwitchButton: some View { + Button(action: { + Task { + let newPosition: AVCaptureDevice.Position = (cameraModel.cameraPosition == .back) ? .front : .back + await cameraModel.switchCamera(to: newPosition) + } + }) { + Image(systemName: "arrow.triangle.2.circlepath.camera") + .font(.system(size: 20)) + .foregroundStyle(cameraModel.isRecording ? .gray : .primary) + .rotatesWithDevice(orientation) + .padding(12) + .glassControlBackground(in: Circle()) + } + .disabled(cameraModel.isRecording) + .accessibilityLabel(cameraModel.cameraPosition == .back ? "Switch to front camera" : "Switch to rear camera") + } + + private var flashButton: some View { + Button(action: { + Logger.ui.info("Flash button tapped, current mode: \(cameraModel.flashMode)") + cameraModel.toggleFlashMode() + }) { + Image(systemName: cameraModel.flashIcon) + .font(.system(size: 20)) + .foregroundStyle((cameraModel.cameraPosition == .front || cameraModel.isRecording) ? .gray : .primary) + .rotatesWithDevice(orientation) + .padding(12) + .glassControlBackground(in: Circle()) + } + .disabled(cameraModel.cameraPosition == .front || cameraModel.isRecording) + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("Flash: \(cameraModel.flashMode == .on ? "on" : cameraModel.flashMode == .off ? "off" : "auto")") + .accessibilityHint("Double-tap to cycle flash mode") + } + + private var recordingIndicator: some View { + HStack(spacing: 8) { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + Text(formatDuration(cameraModel.recordingDurationMs)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .glassControlBackground(in: .rect(cornerRadius: 8)) + .accessibilityLabel("Recording: \(formatDuration(cameraModel.recordingDurationMs))") + .accessibilityAddTraits(.updatesFrequently) + } + + /// Pins the recording capsule to whichever edge is physically up for the + /// current orientation, upright. On rotation it cross-fades between + /// positions rather than swinging around. + private var recordingIndicatorOverlay: some View { + let device = orientation.orientation + let alignment: Alignment + let edge: Edge.Set + let inset: CGFloat + switch device { + case .landscapeLeft: + alignment = .leading; edge = .leading; inset = 12 + case .landscapeRight: + alignment = .trailing; edge = .trailing; inset = 12 + case .portraitUpsideDown: + alignment = .bottom; edge = .bottom; inset = stableSafeInsets.bottom + 8 + default: + alignment = .top; edge = .top; inset = stableSafeInsets.top + 8 + } - Spacer() - - // Capture button - Button(action: { - triggerShutterEffect() - cameraModel.capturePhoto() - }) { - ZStack { - // Background circle - Circle() - .strokeBorder(cameraModel.isPermissionGranted ? Color.white : Color.gray, lineWidth: 4) - .frame(width: 80, height: 80) - .background( - Circle() - .fill(cameraModel.isPermissionGranted ? Color.white : Color.gray.opacity(0.5)) - ) - // Overlay shutter icon - Image("snapshutter") - .resizable() - .scaledToFit() - .frame(width: 90, height: 90) - .foregroundColor(.black) + return recordingIndicator + .rotationEffect(Utils.getRotationAngle(for: device)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + .padding(edge, inset) + .allowsHitTesting(false) + .id(device) + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: device) + } + + private var zoomCapsule: some View { + Text(String(format: "%.1fx", cameraModel.zoomFactor)) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.primary) + .frame(width: 80, height: 30) + .glassControlBackground(in: .capsule) + .opacity(cameraModel.zoomFactor != 1.0 ? 1.0 : 0.0) + .animation(.easeInOut, value: cameraModel.zoomFactor) + .rotationEffect(Utils.getRotationAngle()) + // Enlarge the tap target well beyond the visible capsule so a tap + // "mostly on" it still counts, and make that whole area tappable. + .padding(.horizontal, 24) + .padding(.vertical, 7) + .contentShape(Rectangle()) + .gesture( + TapGesture(count: 2) + .onEnded { _ in + Logger.camera.debug("Double tap detected on zoom indicator") + handleDoubleTabZoomIndicator() + } + .exclusively(before: + TapGesture(count: 1) + .onEnded { _ in + Logger.camera.debug("Single tap detected on zoom indicator") + withAnimation { showZoomSlider = true } } - .padding() - } - .disabled(!cameraModel.isPermissionGranted) - - Spacer() - Button(action: { - nav.navigate(to:.settings) - }) { - Image(systemName: "gear") - .font(.system(size: 24)) - .foregroundColor(.white) - .padding() - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - } + ) + ) + // The capsule is invisible at 1.0x — don't let it silently swallow taps, + // and don't carve a focus-exclusion hole there. + .allowsHitTesting(cameraModel.zoomFactor != 1.0) + .background(focusExclusionReporter(active: cameraModel.zoomFactor != 1.0)) + .accessibilityLabel(String(format: "Zoom: %.1f×", cameraModel.zoomFactor)) + .accessibilityHint("Double-tap to reset zoom. Single-tap to open slider.") + .accessibilityAddTraits(.isButton) + .sensoryFeedback(.impact(weight: .medium), trigger: zoomResetTrigger) + } + + private var modePicker: some View { + Picker("Capture Mode", selection: Binding( + get: { cameraModel.captureMode }, + set: { cameraModel.switchCaptureMode(to: $0) } + )) { + Image(systemName: "camera.fill").tag(CaptureMode.photo) + Image(systemName: "video.fill").tag(CaptureMode.video) + } + .pickerStyle(.segmented) + .frame(width: 120) + .disabled(cameraModel.isRecording) + .accessibilityLabel("Capture mode") + } + + private var galleryButton: some View { + Button(action: { nav.navigate(to: .gallery) }) { + ZStack { + Image(systemName: "photo.on.rectangle") + .font(.title2) + .foregroundStyle( + (cameraModel.isSavingPhoto || cameraModel.isRecording || cameraModel.isSavingVideo) + ? .gray : .primary + ) + .rotatesWithDevice(orientation) .padding() + .glassControlBackground(in: Circle()) + if cameraModel.isSavingPhoto { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.7) } - .padding(.bottom) } } - .animation(.easeInOut(duration: 0.1), value: isShutterAnimating) - .onAppear { - // Start monitoring orientation changes - UIDevice.current.beginGeneratingDeviceOrientationNotifications() - NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, - object: nil, - queue: .main) { _ in - self.deviceOrientation = UIDevice.current.orientation + .disabled(cameraModel.isSavingPhoto || cameraModel.isRecording || cameraModel.isSavingVideo) + .padding() + .accessibilityLabel("Gallery") + .accessibilityHint(cameraModel.isSavingPhoto ? "Saving photo" : "") + } + + private var settingsButton: some View { + Button(action: { nav.navigate(to: .settings) }) { + Image(systemName: "gear") + .font(.title2) + .foregroundStyle((cameraModel.isRecording || cameraModel.isSavingVideo) ? .gray : .primary) + .rotatesWithDevice(orientation) + .padding() + .glassControlBackground(in: Circle()) + } + .disabled(cameraModel.isRecording || cameraModel.isSavingVideo) + .padding() + .accessibilityLabel("Settings") + #if DEBUG + .onLongPressGesture(minimumDuration: 2.0) { + if #available(iOS 18.0, *) { + nav.navigate(to: .videoExportTest) } - - // Initial camera setup - check permissions and configure camera - Task { - await cameraModel.checkAndSetupCamera() + } + #endif + } + + private var captureButton: some View { + Group { + if cameraModel.captureMode == .photo { + photoShutterButton + } else { + videoRecordButton + } + } + } + + private var photoShutterButton: some View { + Button(action: { + shutterFeedbackTrigger += 1 + triggerShutterEffect() + cameraModel.capturePhoto() + }) { + ZStack { + // Glass interior (sized to the ring's inner edge) so the live + // image shows through instead of a solid white slab. + Image("snapshutter") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 52, height: 52) + .foregroundStyle(cameraModel.isPermissionGranted ? Color.white : Color.gray) + .frame(width: 72, height: 72) + .glassControlBackground(in: Circle()) + Circle() + .strokeBorder(cameraModel.isPermissionGranted ? Color.white : Color.gray, lineWidth: 4) + .frame(width: 80, height: 80) + } + .frame(width: 90, height: 90) + .contentShape(Circle()) + .padding() + } + .disabled(!cameraModel.isPermissionGranted) + .sensoryFeedback(.impact(weight: .medium), trigger: shutterFeedbackTrigger) + .accessibilityLabel("Take photo") + .accessibilityHint(cameraModel.isPermissionGranted ? "" : "Camera access required") + } + + private var videoRecordButton: some View { + Button(action: { + cameraModel.toggleRecording() + }) { + ZStack { + // Standard iOS record control: white ring, glass interior, red + // core that becomes a square while recording. + Group { + if cameraModel.isRecording { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red) + .frame(width: 32, height: 32) + } else { + Circle() + .fill(Color.red) + .frame(width: 36, height: 36) + } + } + .frame(width: 72, height: 72) + .glassControlBackground(in: Circle()) + Circle() + .strokeBorder(cameraModel.isPermissionGranted ? Color.white : Color.gray, lineWidth: 4) + .frame(width: 80, height: 80) } + .frame(width: 90, height: 90) + .contentShape(Circle()) + .padding() + } + .disabled(!cameraModel.isPermissionGranted) + .sensoryFeedback(.impact(weight: .heavy), trigger: cameraModel.isRecording) { old, new in + old == false && new == true } - .onDisappear { - // Stop monitoring orientation changes - NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil) - UIDevice.current.endGeneratingDeviceOrientationNotifications() + .sensoryFeedback(.impact(weight: .medium), trigger: cameraModel.isRecording) { old, new in + old == true && new == false } + .accessibilityLabel(cameraModel.isRecording ? "Stop recording" : "Start recording") + .accessibilityHint(cameraModel.isPermissionGranted ? "" : "Camera access required") } - - // MARK: - Private Methods - + + // MARK: - Helpers + private func triggerShutterEffect() { isShutterAnimating = true Task { @@ -226,31 +475,75 @@ struct CameraContainerView: View { private func handleDoubleTabZoomIndicator() { cameraModel.resetZoomLevel() - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() + zoomResetTrigger += 1 } + private func formatDuration(_ milliseconds: Int64) -> String { + let totalSeconds = Int(milliseconds / 1000) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } } #Preview { - // Create a mock camera permission repository with granted permissions for preview -// @MainActor -// class MockCameraPermissionRepository: CameraPermissionRepository { -// override init() { -// super.init() -// // Force permission to be granted for preview -// Task { -// await self.checkAndUpdatePermissions() -// } -// } -// -// // Override to always return true for preview -// override var isPermissionGranted: Bool { -// return true -// } -// } - - return CameraContainerView() + CameraContainerView() .environmentObject(AppNavigationState()) -// .environmentObject(MockCameraPermissionRepository()) +} + +// MARK: - Focus exclusion + +/// Collects the frames of overlaid controls (mode toggle, zoom capsule) that +/// should suppress tap-to-focus on the preview beneath them. +private struct FocusExclusionPreferenceKey: PreferenceKey { + static let defaultValue: [CGRect] = [] + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value.append(contentsOf: nextValue()) + } +} + +// MARK: - Liquid Glass control background + +private extension View { + /// Applies a translucent, contrasting control background per the Apple HIG: + /// Liquid Glass on iOS 26+, with an `.ultraThinMaterial` fallback on earlier + /// versions (the deployment floor is iOS 18.5). + /// + /// Uses the CLEAR glass variant, not regular: these controls float over the + /// live viewfinder, where regular glass renders as a near-opaque dark disc. + /// Per the HIG, clear glass over media needs a dim layer beneath it for + /// symbol legibility — hence the black tint inside the shape. + /// + /// The glass is intentionally NOT `.interactive()`: these backgrounds live + /// inside `Button`s (and tap gestures), and interactive glass installs its + /// own touch handling that swallows the button's tap. The enclosing control + /// provides the interaction; this modifier is purely the visual background. + @ViewBuilder + func glassControlBackground(in shape: some Shape) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.clear, in: shape) + .background(.black.opacity(0.25), in: shape) + } else { + self.background(.ultraThinMaterial, in: shape) + } + } + + /// Rotates a control's glyph to stay upright relative to the ground while + /// the camera UI itself stays locked to portrait (iOS Camera style). + /// `rotationEffect` does not affect layout, so the control never moves. + func rotatesWithDevice(_ observer: OrientationObserver) -> some View { + self + .rotationEffect(Utils.getRotationAngle(for: observer.orientation)) + .animation(.easeInOut(duration: 0.25), value: observer.orientation) + } + + /// Hides a control while video recording is active so only the shutter is + /// visible. Opacity fades; hit-testing is disabled in lockstep so the + /// invisible button can't be tapped. + func hideWhileRecording(_ isRecording: Bool) -> some View { + self + .opacity(isRecording ? 0 : 1) + .allowsHitTesting(!isRecording) + } } diff --git a/SnapSafe/Screens/Camera/CameraView.swift b/SnapSafe/Screens/Camera/CameraView.swift index 9c08b0c..7303e98 100644 --- a/SnapSafe/Screens/Camera/CameraView.swift +++ b/SnapSafe/Screens/Camera/CameraView.swift @@ -17,13 +17,11 @@ import Logging // SwiftUI wrapper for the camera preview struct CameraView: View { @ObservedObject var cameraModel: CameraViewModel + var focusExclusionRects: [CGRect] = [] var onPinchStarted: (() -> Void)? var onPinchChanged: (() -> Void)? var onPinchEnded: (() -> Void)? - // Add a slightly darker background to emphasize the capture area - let backgroundOpacity: Double = 0.2 - @State private var showBlackOverlay = false var body: some View { @@ -35,7 +33,7 @@ struct CameraView: View { if cameraModel.isPermissionGranted { // Camera preview represented by UIViewRepresentable - CameraPreviewView(cameraModel: cameraModel, viewSize: geometry.size, onPinchStarted: onPinchStarted, onPinchChanged: onPinchChanged, onPinchEnded: onPinchEnded) + CameraPreviewView(cameraModel: cameraModel, viewSize: geometry.size, focusExclusionRects: focusExclusionRects, onPinchStarted: onPinchStarted, onPinchChanged: onPinchChanged, onPinchEnded: onPinchEnded) .edgesIgnoringSafeArea(.all) // Black overlay when returning from background @@ -57,16 +55,16 @@ struct CameraView: View { VStack(spacing: 20) { Image(systemName: "camera.fill") .font(.system(size: 60)) - .foregroundColor(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(0.6)) Text("Camera Access Disabled") .font(.title2) .fontWeight(.semibold) - .foregroundColor(.white) + .foregroundStyle(.white) Text("Camera access is required to take photos. Please enable camera access in Settings.") .font(.body) - .foregroundColor(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.8)) .multilineTextAlignment(.center) .padding(.horizontal, 40) @@ -79,12 +77,12 @@ struct CameraView: View { Image(systemName: "gear") Text("Open Settings") } - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) + .font(.callout) + .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Color.blue) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } } } @@ -151,31 +149,27 @@ struct FocusIndicatorView: View { } } +// Persistent camera preview state; lives on the Coordinator so it survives struct re-renders +class CameraPreviewHolder { + var previewLayer: AVCaptureVideoPreviewLayer? + var previewContainer: UIView? +} + // UIViewRepresentable for camera preview struct CameraPreviewView: UIViewRepresentable { - private let sessionQueue = DispatchQueue(label: "camera.session.queue") - @ObservedObject var cameraModel: CameraViewModel var viewSize: CGSize // Store the parent view's size for coordinate conversion + // Regions (in the full-screen root view's coordinate space) where the + // overlaid SwiftUI controls live; the focus tap gestures decline touches + // here so those controls handle the tap instead. + var focusExclusionRects: [CGRect] = [] var onPinchStarted: (() -> Void)? var onPinchChanged: (() -> Void)? var onPinchEnded: (() -> Void)? - // Standard photo aspect ratio is 4:3 - // This is the ratio of most iPhone photos in portrait mode (3:4 actually, as width:height) - private let photoAspectRatio: CGFloat = 3.0 / 4.0 // width/height in portrait mode - - // Store the view reference to help with coordinate mapping - class CameraPreviewHolder { - weak var view: UIView? - var previewLayer: AVCaptureVideoPreviewLayer? - var previewContainer: UIView? // Container with correct aspect ratio - } - - // Shared holder to maintain a reference to the view and preview layer - private let viewHolder = CameraPreviewHolder() - func makeUIView(context: Context) -> UIView { + let holder = context.coordinator.viewHolder + // Create a view with the exact size passed from parent let view = UIView(frame: CGRect(origin: .zero, size: viewSize)) Logger.camera.debug("Creating camera preview", metadata: [ @@ -183,95 +177,23 @@ struct CameraPreviewView: UIViewRepresentable { "height": .stringConvertible(viewSize.height) ]) - // Store the view reference - viewHolder.view = view - // Calculate the container size to match photo aspect ratio let containerSize = calculatePreviewContainerSize(for: viewSize) let containerOrigin = CGPoint( x: (viewSize.width - containerSize.width) / 2, y: (viewSize.height - containerSize.height) / 2 ) - - // Create the container view with proper aspect ratio + + // Create the container view with proper aspect ratio. The capture + // area needs no border or corner brackets: the preview is clipped to + // exactly the capture aspect, so the letterbox bands already mark it, + // and frame lines just collide with the overlaid controls. let containerView = UIView(frame: CGRect(origin: containerOrigin, size: containerSize)) containerView.backgroundColor = .clear containerView.clipsToBounds = true view.addSubview(containerView) - viewHolder.previewContainer = containerView - - // Add visual guides for the capture area - - // 1. Add a border to visualize the capture area - let borderLayer = CALayer() - borderLayer.frame = containerView.bounds - borderLayer.borderColor = UIColor.white.withAlphaComponent(0.7).cgColor - borderLayer.borderWidth = 2.0 - containerView.layer.addSublayer(borderLayer) - - // 2. Add corner brackets for a more camera-like appearance - let cornerSize: CGFloat = 20.0 - let cornerThickness: CGFloat = 3.0 - let cornerColor = UIColor.white.withAlphaComponent(0.8).cgColor - - // Top-left corner - let topLeftCornerH = CALayer() - topLeftCornerH.frame = CGRect(x: 0, y: 0, width: cornerSize, height: cornerThickness) - topLeftCornerH.backgroundColor = cornerColor - containerView.layer.addSublayer(topLeftCornerH) - - let topLeftCornerV = CALayer() - topLeftCornerV.frame = CGRect(x: 0, y: 0, width: cornerThickness, height: cornerSize) - topLeftCornerV.backgroundColor = cornerColor - containerView.layer.addSublayer(topLeftCornerV) - - // Top-right corner - let topRightCornerH = CALayer() - topRightCornerH.frame = CGRect(x: containerSize.width - cornerSize, y: 0, width: cornerSize, height: cornerThickness) - topRightCornerH.backgroundColor = cornerColor - containerView.layer.addSublayer(topRightCornerH) - - let topRightCornerV = CALayer() - topRightCornerV.frame = CGRect(x: containerSize.width - cornerThickness, y: 0, width: cornerThickness, height: cornerSize) - topRightCornerV.backgroundColor = cornerColor - containerView.layer.addSublayer(topRightCornerV) - - // Bottom-left corner - let bottomLeftCornerH = CALayer() - bottomLeftCornerH.frame = CGRect(x: 0, y: containerSize.height - cornerThickness, width: cornerSize, height: cornerThickness) - bottomLeftCornerH.backgroundColor = cornerColor - containerView.layer.addSublayer(bottomLeftCornerH) - - let bottomLeftCornerV = CALayer() - bottomLeftCornerV.frame = CGRect(x: 0, y: containerSize.height - cornerSize, width: cornerThickness, height: cornerSize) - bottomLeftCornerV.backgroundColor = cornerColor - containerView.layer.addSublayer(bottomLeftCornerV) - - // Bottom-right corner - let bottomRightCornerH = CALayer() - bottomRightCornerH.frame = CGRect(x: containerSize.width - cornerSize, y: containerSize.height - cornerThickness, width: cornerSize, height: cornerThickness) - bottomRightCornerH.backgroundColor = cornerColor - containerView.layer.addSublayer(bottomRightCornerH) - - let bottomRightCornerV = CALayer() - bottomRightCornerV.frame = CGRect(x: containerSize.width - cornerThickness, y: containerSize.height - cornerSize, width: cornerThickness, height: cornerSize) - bottomRightCornerV.backgroundColor = cornerColor - containerView.layer.addSublayer(bottomRightCornerV) - - // Add a label to indicate that this is the capture area - let captureLabel = UILabel() - captureLabel.text = "CAPTURE AREA" - captureLabel.textColor = UIColor.white.withAlphaComponent(0.7) - captureLabel.font = UIFont.systemFont(ofSize: 10, weight: .bold) - captureLabel.sizeToFit() - captureLabel.frame = CGRect( - x: (containerSize.width - captureLabel.frame.width) / 2, - y: 10, - width: captureLabel.frame.width, - height: captureLabel.frame.height - ) - containerView.addSubview(captureLabel) - + holder.previewContainer = containerView + // Create and configure the preview layer let previewLayer = AVCaptureVideoPreviewLayer() previewLayer.session = cameraModel.session @@ -280,7 +202,7 @@ struct CameraPreviewView: UIViewRepresentable { previewLayer.connection?.videoRotationAngle = 90 // Force portrait orientation // Store the preview layer in our holder instead of directly in the cameraModel - viewHolder.previewLayer = previewLayer + holder.previewLayer = previewLayer // Ensure the layer is added to the container view containerView.layer.addSublayer(previewLayer) @@ -291,11 +213,15 @@ struct CameraPreviewView: UIViewRepresentable { let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.handleDoubleTapGesture(_:))) doubleTapGesture.numberOfTapsRequired = 2 + // Only claim taps inside the capture area; let taps on the surrounding + // SwiftUI controls (flash, switch, gallery, etc.) fall through. + doubleTapGesture.delegate = context.coordinator view.addGestureRecognizer(doubleTapGesture) // Add single tap gesture for quick focus let singleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.handleSingleTapGesture(_:))) singleTapGesture.requiresExclusiveTouchType = true + singleTapGesture.delegate = context.coordinator // Ensure single tap doesn't conflict with double tap singleTapGesture.require(toFail: doubleTapGesture) @@ -315,170 +241,108 @@ struct CameraPreviewView: UIViewRepresentable { return view } - // Calculate the container size based on the photo aspect ratio private func calculatePreviewContainerSize(for size: CGSize) -> CGSize { - // Calculate the container size to match photo aspect ratio - // In portrait mode, we're comparing width:height - // We prioritize fitting the width to match the device's screen width - let width = size.width - let height = width / photoAspectRatio - - // If height exceeds the available space, adjust both dimensions - if height > size.height { - // Use the available height - let adjustedHeight = size.height - let adjustedWidth = adjustedHeight * photoAspectRatio - return CGSize(width: adjustedWidth, height: adjustedHeight) - } else { - return CGSize(width: width, height: height) - } + CameraPreviewLayout.containerSize(for: size, aspectRatio: cameraModel.captureAspectRatio) } - func updateUIView(_ uiView: UIView, context _: Context) { - // Update the preview layer frame when the view updates - Task { @MainActor in - // Update frame with the latest size - uiView.frame = CGRect(origin: .zero, size: viewSize) - - // Calculate the container size to match photo aspect ratio - let containerSize = calculatePreviewContainerSize(for: viewSize) - let containerOrigin = CGPoint( - x: (viewSize.width - containerSize.width) / 2, - y: (viewSize.height - containerSize.height) / 2 - ) - - // Update the container view frame - if let containerView = viewHolder.previewContainer { - containerView.frame = CGRect(origin: containerOrigin, size: containerSize) - - // Update the preview layer frame to match container - if let layer = viewHolder.previewLayer { - layer.frame = containerView.bounds - - // Ensure we're using the correct layer in the camera model - // Only update if necessary to avoid excessive property changes - if cameraModel.preview !== layer { - cameraModel.preview = layer - } - } - - // Update all visual indicators - if containerView.layer.sublayers?.count ?? 0 > 0 { - // Update border - if let borderLayer = containerView.layer.sublayers?.first(where: { $0.borderWidth > 0 }) { - borderLayer.frame = containerView.bounds - } - - // Update corner guides - let cornerSize: CGFloat = 20.0 - let cornerThickness: CGFloat = 3.0 - - // Find corner guides by their size and position - for layer in containerView.layer.sublayers ?? [] { - // Skip the border layer - if layer.borderWidth > 0 { continue } - - // Update corner layers based on their position - if layer.frame.origin.x == 0 && layer.frame.origin.y == 0 { - // Top-left horizontal - if layer.frame.height == cornerThickness { - layer.frame = CGRect(x: 0, y: 0, width: cornerSize, height: cornerThickness) - } - // Top-left vertical - else if layer.frame.width == cornerThickness { - layer.frame = CGRect(x: 0, y: 0, width: cornerThickness, height: cornerSize) - } - } - else if layer.frame.origin.y == 0 && layer.frame.origin.x > 0 { - // Top-right horizontal - if layer.frame.height == cornerThickness { - layer.frame = CGRect(x: containerSize.width - cornerSize, y: 0, width: cornerSize, height: cornerThickness) - } - // Top-right vertical - else if layer.frame.width == cornerThickness { - layer.frame = CGRect(x: containerSize.width - cornerThickness, y: 0, width: cornerThickness, height: cornerSize) - } - } - else if layer.frame.origin.x == 0 && layer.frame.origin.y > 0 { - // Bottom-left horizontal - if layer.frame.height == cornerThickness { - layer.frame = CGRect(x: 0, y: containerSize.height - cornerThickness, width: cornerSize, height: cornerThickness) - } - // Bottom-left vertical - else if layer.frame.width == cornerThickness { - layer.frame = CGRect(x: 0, y: containerSize.height - cornerSize, width: cornerThickness, height: cornerSize) - } - } - else if layer.frame.origin.x > 0 && layer.frame.origin.y > 0 { - // Bottom-right horizontal - if layer.frame.height == cornerThickness { - layer.frame = CGRect(x: containerSize.width - cornerSize, y: containerSize.height - cornerThickness, width: cornerSize, height: cornerThickness) - } - // Bottom-right vertical - else if layer.frame.width == cornerThickness { - layer.frame = CGRect(x: containerSize.width - cornerThickness, y: containerSize.height - cornerSize, width: cornerThickness, height: cornerSize) - } - } - } - - // Update the capture area label position - for subview in containerView.subviews { - if let label = subview as? UILabel, label.text == "CAPTURE AREA" { - label.frame = CGRect( - x: (containerSize.width - label.frame.width) / 2, - y: 10, - width: label.frame.width, - height: label.frame.height - ) + func updateUIView(_ uiView: UIView, context: Context) { + // Keep the persistent coordinator pointed at the latest struct so its + // gesture delegate reads the current focus-exclusion rects. + context.coordinator.parent = self + + let holder = context.coordinator.viewHolder + uiView.frame = CGRect(origin: .zero, size: viewSize) + + let containerSize = calculatePreviewContainerSize(for: viewSize) + let containerOrigin = CGPoint( + x: (viewSize.width - containerSize.width) / 2, + y: (viewSize.height - containerSize.height) / 2 + ) + + if let containerView = holder.previewContainer { + containerView.frame = CGRect(origin: containerOrigin, size: containerSize) + + if let layer = holder.previewLayer { + layer.frame = containerView.bounds + if cameraModel.preview !== layer { + // Defer the @Published mutation off the view-update cycle to + // avoid "Publishing changes from within view updates" (matches + // the pattern used in makeUIView). + Task { @MainActor in + if cameraModel.preview !== layer { + cameraModel.preview = layer } } } } - // Update the size in the model - cameraModel.viewSize = containerSize // Store the actual photo preview size - //print("📐 Updated camera preview to size: \(containerSize.width)x\(containerSize.height)") + } + + if cameraModel.viewSize != containerSize { + cameraModel.viewSize = containerSize } } // This method is called once after makeUIView func makeCoordinator() -> Coordinator { - // Create coordinator first - this shouldn't trigger camera operations let coordinator = Coordinator(self) - - // Capture cameraModel to avoid potential reference issues + let capturedCameraModel = cameraModel - - // Give a slight delay before starting the camera session - // This ensures all UI setup is complete and configuration has been committed Task(priority: .userInitiated) { try await Task.sleep(for: .milliseconds(500)) - // Start camera on background thread after delay let session = capturedCameraModel.session await withCheckedContinuation { (cont: CheckedContinuation) in - sessionQueue.async { + coordinator.sessionQueue.async { if !session.isRunning { Logger.camera.debug("Starting camera session off-main after delay") - session.startRunning() // blocking; safe on this queue + session.startRunning() } cont.resume() } } } - + return coordinator } // Coordinator for handling UIKit gestures @MainActor - class Coordinator: NSObject { + class Coordinator: NSObject, UIGestureRecognizerDelegate { var parent: CameraPreviewView private var initialScale: CGFloat = 1.0 + // Persistent state across re-renders (struct properties are recreated each render) + let sessionQueue = DispatchQueue(label: "camera.session.queue") + let viewHolder = CameraPreviewHolder() + init(_ parent: CameraPreviewView) { self.parent = parent } + // Only let the focus tap recognizers claim touches that land inside the + // capture area. Touches outside it are on the overlaid SwiftUI controls + // (or the letterbox), so declining them here lets those controls receive + // the tap instead of the preview's focus gesture swallowing it. + nonisolated func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + MainActor.assumeIsolated { + guard let container = viewHolder.previewContainer else { return true } + + // Decline taps that land on the overlaid SwiftUI controls (mode + // toggle, zoom capsule) so those controls handle the tap rather + // than tap-to-focus firing underneath. Exclusion rects are in + // the root view's coordinate space. + let pointInRoot = touch.location(in: gestureRecognizer.view) + if parent.focusExclusionRects.contains(where: { $0.contains(pointInRoot) }) { + return false + } + + return container.bounds.contains(touch.location(in: container)) + } + } + // Handle pinch gesture for zoom with continuous updates @objc func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) { switch gesture.state { @@ -514,7 +378,7 @@ struct CameraPreviewView: UIViewRepresentable { ]) // Get the container view for proper coordinate conversion - guard let containerView = parent.viewHolder.previewContainer else { return } + guard let containerView = viewHolder.previewContainer else { return } // Check if the tap is within the container bounds let locationInContainer = view.convert(location, to: containerView) @@ -525,7 +389,7 @@ struct CameraPreviewView: UIViewRepresentable { // Convert touch point to camera coordinate - if let layer = parent.viewHolder.previewLayer { + if let layer = viewHolder.previewLayer { // Convert the point from the container's coordinate space to the preview layer's coordinate space let pointInPreviewLayer = layer.captureDevicePointConverted(fromLayerPoint: locationInContainer) let devicePoint = layer.devicePoint(from: location) @@ -554,7 +418,7 @@ struct CameraPreviewView: UIViewRepresentable { ]) // Get the container view for proper coordinate conversion - guard let containerView = parent.viewHolder.previewContainer else { return } + guard let containerView = viewHolder.previewContainer else { return } // Check if the tap is within the container bounds let locationInContainer = view.convert(location, to: containerView) @@ -564,7 +428,7 @@ struct CameraPreviewView: UIViewRepresentable { } // Convert touch point to camera coordinate - if let layer = parent.viewHolder.previewLayer { + if let layer = viewHolder.previewLayer { // Convert the point from the container's coordinate space to the preview layer's coordinate space let pointInPreviewLayer = layer.captureDevicePointConverted(fromLayerPoint: locationInContainer) Logger.camera.debug("Converted to camera coordinates (1x tap)", metadata: [ @@ -586,7 +450,5 @@ extension AVCaptureVideoPreviewLayer { return self.captureDevicePointConverted(fromLayerPoint: viewPoint) } - func viewPoint(from devicePoint: CGPoint) -> CGPoint { - return self.layerPointConverted(fromCaptureDevicePoint: devicePoint) - } + } diff --git a/SnapSafe/Screens/Camera/CameraViewModel.swift b/SnapSafe/Screens/Camera/CameraViewModel.swift index fa35ced..4586bc4 100644 --- a/SnapSafe/Screens/Camera/CameraViewModel.swift +++ b/SnapSafe/Screens/Camera/CameraViewModel.swift @@ -4,22 +4,20 @@ // // Created by Bill Booth on 5/24/25. // -import AVFoundation +@preconcurrency import AVFoundation +import CoreMedia import SwiftUI import FactoryKit import Logging import Combine - -enum CameraLensType { - case ultraWide // 0.5x zoom - case wideAngle // 1x zoom (standard) -} +import CryptoKit // Camera model that handles the AVFoundation functionality @MainActor class CameraViewModel: NSObject, ObservableObject { // MARK: - Debug/Simulator Detection + // periphery:ignore private var isRunningInSimulator: Bool { #if DEBUG && targetEnvironment(simulator) return true @@ -28,38 +26,62 @@ class CameraViewModel: NSObject, ObservableObject { #endif } // MARK: - Services - + private let permissionService = CameraPermissionService() private let deviceService = CameraDeviceService() private let zoomService = CameraZoomService() private let focusService = CameraFocusService() private let photoService = PhotoCaptureService() - + private let videoService = VideoCaptureService() + var isPermissionGranted: Bool { permissionService.isPermissionGranted } var session: AVCaptureSession { deviceService.session } var output: AVCapturePhotoOutput { deviceService.output } var currentDevice: AVCaptureDevice? { deviceService.currentDevice } + + /// Portrait aspect (width/height) of the active capture format. The + /// preview container uses this so what's on screen is exactly what gets + /// captured. Falls back to 9:16 (.high preset) before setup completes. + var captureAspectRatio: CGFloat { + guard let format = currentDevice?.activeFormat else { return 9.0 / 16.0 } + let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + return CameraPreviewLayout.portraitAspectRatio( + formatWidth: dims.width, + formatHeight: dims.height + ) + } + var zoomFactor: CGFloat { zoomService.zoomFactor } var minZoom: CGFloat { zoomService.minZoom } var maxZoom: CGFloat { zoomService.maxZoom } - var currentLensType: CameraLensType { zoomService.currentLensType } var focusIndicatorPoint: CGPoint? { focusService.focusIndicatorPoint } var showingFocusIndicator: Bool { focusService.showingFocusIndicator } - var recentImage: UIImage? { photoService.recentImage } var isSavingPhoto: Bool { photoService.isSavingPhoto } + // Video capture properties + var isRecording: Bool { videoService.isRecording } + var recordingDurationMs: Int64 { videoService.recordingDurationMs } + @Published var alert = false @Published var preview: AVCaptureVideoPreviewLayer! - - + @Published var captureMode: CaptureMode = .photo + + // Video saving state. The gate keeps the "Saving…" HUD visible for a + // minimum duration so short clips don't flash it on and off faster than + // the user can read it; it shows from the moment recording stops (not + // just while encrypting) so the finalize/thumbnail gap is covered too. + private let videoSavingGate = MinimumVisibilityGate(minimumDuration: 1.0) + var isSavingVideo: Bool { videoSavingGate.isVisible } + @Published var encryptionProgress: Double = 0 + @Injected(\.secureImageRepository) private var secureImageRepository: SecureImageRepository - - @Injected(\.clock) - private var clock: Clock - - @Injected(\.locationRepository) - private var locationRepository: LocationRepository + + @Injected(\.videoEncryptionService) + private var videoEncryptionService: VideoEncryptionService + + @Injected(\.encryptionScheme) + private var encryptionScheme: EncryptionScheme @@ -67,7 +89,6 @@ class CameraViewModel: NSObject, ObservableObject { var viewSize: CGSize = .zero @Published var flashMode: AVCaptureDevice.FlashMode = .auto var cameraPosition: AVCaptureDevice.Position { deviceService.cameraPosition } - @Published var isTogglingFlash = false // Combine subscriptions private var cancellables = Set() @@ -78,6 +99,29 @@ class CameraViewModel: NSObject, ObservableObject { override init() { super.init() + // Wire video recording callback to trigger encryption + videoService.onRecordingFinished = { [weak self] outputURL in + self?.encryptRecordedVideo(at: outputURL) + } + + // Release the mic once recording fully finalizes (success or failure). + videoService.onRecordingStopped = { [weak self] in + self?.deviceService.detachAudioInput() + } + + // A failed recording produces no file to encrypt, so nothing else + // will release the saving HUD — release it here. + videoService.onRecordingFailed = { [weak self] in + self?.videoSavingGate.hide() + } + + // Observe device service changes (drives captureAspectRatio) + deviceService.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + // Observe permission changes from the service permissionService.objectWillChange .sink { [weak self] _ in @@ -106,6 +150,20 @@ class CameraViewModel: NSObject, ObservableObject { } .store(in: &cancellables) + // Observe video service changes + videoService.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + // Observe saving-HUD gate changes (drives isSavingVideo) + videoSavingGate.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + // Listen for app lifecycle events to restart camera and reset zoom NotificationCenter.default.addObserver( self, @@ -140,6 +198,10 @@ class CameraViewModel: NSObject, ObservableObject { @objc private func handleAppWillResignActive() { Logger.camera.info("App will resign active, stopping camera") + // Stop any active recording before stopping session + if isRecording { + stopRecording() + } stopCameraSession() } @@ -182,7 +244,8 @@ class CameraViewModel: NSObject, ObservableObject { if isGranted { Task { try await Task.sleep(for: .milliseconds(200)) - await deviceService.setupCamera(for: cameraPosition, lensType: currentLensType) + await deviceService.setupCamera(for: cameraPosition) + zoomService.updateZoomLimits(for: currentDevice) } } else { await MainActor.run { @@ -192,6 +255,7 @@ class CameraViewModel: NSObject, ObservableObject { } + // periphery:ignore func setupCamera() async { #if DEBUG && targetEnvironment(simulator) if isRunningInSimulator { @@ -200,8 +264,8 @@ class CameraViewModel: NSObject, ObservableObject { } #endif - await deviceService.setupCamera(for: cameraPosition, lensType: currentLensType) - + await deviceService.setupCamera(for: cameraPosition) + // Update zoom limits based on device zoomService.updateZoomLimits(for: currentDevice) @@ -224,23 +288,13 @@ class CameraViewModel: NSObject, ObservableObject { #endif + // periphery:ignore private func prepareZeroShutterLagCapture() { // TODO/debug return } - // Map device orientations to rotation angles for horizon-level capture - private func rotationAngle(for orientation: UIDeviceOrientation) -> Double { - switch orientation { - case .portrait: return 90 - case .portraitUpsideDown: return 270 - case .landscapeLeft: return 0 - case .landscapeRight: return 180 - default: return 0 - } - } - func capturePhoto() { #if DEBUG && targetEnvironment(simulator) if isRunningInSimulator { @@ -250,7 +304,7 @@ class CameraViewModel: NSObject, ObservableObject { return } #endif - + photoService.capturePhoto( flashMode: flashMode, cameraPosition: cameraPosition, @@ -259,56 +313,113 @@ class CameraViewModel: NSObject, ObservableObject { session: session ) } - - + + // MARK: - Capture Mode & Video Recording + + /// Switch between photo and video capture modes + func switchCaptureMode(to mode: CaptureMode) { + guard mode != captureMode else { return } + + // Stop any active recording before switching modes + if isRecording { + stopRecording() + } + + captureMode = mode + deviceService.configureForMode(mode) + + // Mode switches always start back at the default zoom + resetZoomLevel() + + Logger.camera.info("Switched capture mode to: \(String(describing: mode))") + } + + /// Start video recording + @discardableResult + func startRecording() -> URL? { + #if DEBUG && targetEnvironment(simulator) + if isRunningInSimulator { + Logger.camera.warning("Video recording not supported in simulator") + return nil + } + #endif + + guard captureMode == .video else { + Logger.camera.warning("Cannot start recording - not in video mode") + return nil + } + + // Attach the mic only now, immediately before recording — so toggling + // into video mode never reconfigures the session (no preview flicker) + // and the mic indicator appears only while actually recording. + deviceService.attachAudioInput() + + let outputURL = videoService.startRecording( + session: session, + movieOutput: deviceService.movieOutput, + preview: preview + ) + + // If recording never actually started, no finish delegate will fire to + // release the mic, so release it here. + if outputURL == nil { + deviceService.detachAudioInput() + } + + return outputURL + } + + /// Stop video recording + func stopRecording() { + guard isRecording else { return } + + // Show the saving HUD now, before finalization/encryption begins, so + // the whole stop→encrypt→save pipeline reads as one operation. Reset + // the progress first so the bar doesn't show the previous clip's 100%. + encryptionProgress = 0 + videoSavingGate.show() + + // The mic is released once finalization completes (onRecordingStopped), + // not here — removing the input mid-finalization could truncate audio. + videoService.stopRecording() + } + + /// Toggle video recording state + func toggleRecording() { + if isRecording { + stopRecording() + } else { + startRecording() + } + } + // Smooth zoom with lens-specific adjustments and auto mode restoration func zoom(factor: CGFloat) async { await zoomService.zoom(factor: factor, device: currentDevice) } - // Handle pinch gestures with automatic lens switching and smooth zoom + // Handle pinch gestures; the virtual device zooms seamlessly across lenses func handlePinchGesture(scale: CGFloat, initialScale: CGFloat? = nil) { zoomService.handlePinchGesture( scale: scale, initialScale: initialScale, - device: currentDevice, - onLensSwitch: { [weak self] lensType in - self?.switchLensType(to: lensType) - } + device: currentDevice ) } - + // Tap-to-focus with optional white balance locking func adjustCameraSettings(at point: CGPoint, lockWhiteBalance: Bool = false) { focusService.adjustCameraSettings(at: point, lockWhiteBalance: lockWhiteBalance, device: currentDevice) } - - - // Switch between ultra-wide and wide-angle cameras - func switchLensType(to lensType: CameraLensType) { - guard lensType != currentLensType else { return } - guard cameraPosition == .back || lensType == .wideAngle else { return } - - zoomService.updateLensType(lensType) - deviceService.switchLensType(to: lensType) - - // Set up focus monitoring for the new device - if let device = currentDevice { - focusService.setupSubjectAreaChangeMonitoring(for: device) - } - } - + // Switch between front and back cameras with clean white balance reset func switchCamera(to position: AVCaptureDevice.Position) async { - if position == .front && currentLensType == .ultraWide { - zoomService.updateLensType(.wideAngle) - } - await deviceService.switchCamera(to: position) - - // Update zoom after camera switch - zoomService.resetZoomLevel(device: currentDevice) - + + // Rebuild the zoom mapping for the new device (front cameras have no + // ultra-wide lens); setupCamera already positioned it at display 1.0x. + zoomService.updateZoomLimits(for: currentDevice) + // Set up focus monitoring for the new device if let device = currentDevice { focusService.setupSubjectAreaChangeMonitoring(for: device) @@ -336,17 +447,9 @@ class CameraViewModel: NSObject, ObservableObject { func toggleFlashMode() { - // Prevent rapid consecutive toggles - guard !isTogglingFlash else { - Logger.camera.debug("Flash toggle ignored - already in progress") - return - } - - isTogglingFlash = true - let currentMode = flashMode let newMode: AVCaptureDevice.FlashMode - + switch currentMode { case .auto: newMode = .on @@ -357,26 +460,15 @@ class CameraViewModel: NSObject, ObservableObject { @unknown default: newMode = .auto } - + + // Cycling the flash mode is a synchronous, idempotent state change — it + // only takes effect at capture time — so there's nothing to debounce. + flashMode = newMode + Logger.camera.debug("Flash mode cycling", metadata: [ "from": .string(String(describing: currentMode)), "to": .string(String(describing: newMode)) ]) - - // Update the flash mode - flashMode = newMode - - Logger.camera.debug("Flash mode updated", metadata: [ - "mode": .string(String(describing: flashMode)) - ]) - - // Re-enable toggling after a brief delay - Task { - try await Task.sleep(for: .milliseconds(100)) - await MainActor.run { - self.isTogglingFlash = false - } - } } var flashIcon: String { @@ -391,4 +483,68 @@ class CameraViewModel: NSObject, ObservableObject { return "bolt.badge.a" } } + + // MARK: - Video Encryption + + private func encryptRecordedVideo(at movURL: URL) { + Task { + do { + let keyData = try await encryptionScheme.getDerivedKey() + let symmetricKey = SymmetricKey(data: keyData) + + // Generate the gallery thumbnail from the plaintext .mov now, + // while it still exists (it is deleted after encryption). + let videoName = movURL.deletingPathExtension().lastPathComponent + await secureImageRepository.generateAndStoreVideoThumbnail( + forVideoNamed: videoName, + fromPlaintextVideo: movURL + ) + + // Build .secv output path alongside the .mov + let secvURL = movURL.deletingPathExtension().appendingPathExtension(SECVFileFormat.FILE_EXTENSION) + + // Create empty output file (FileHandle(forWritingTo:) requires it to exist) + FileManager.default.createFile(atPath: secvURL.path, contents: nil) + + encryptionProgress = 0 + + let (progress, _) = videoEncryptionService.encryptVideo( + inputURL: movURL, + outputURL: secvURL, + encryptionKey: symmetricKey + ) + + // The stream always finishes; success is "reached 1.0 first". + // Doing cleanup on completion (not on the 1.0 value) means the + // .secv trailer is fully written before the .mov is deleted, + // and a failed encryption still releases the saving HUD. + progress + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] _ in + guard let self else { return } + if self.encryptionProgress >= 1.0 { + // Delete the temp .mov file + try? FileManager.default.removeItem(at: movURL) + Logger.camera.info("Video encrypted and temp file deleted", metadata: [ + "output": .string(secvURL.lastPathComponent) + ]) + } else { + Logger.camera.error("Video encryption did not complete", metadata: [ + "output": .string(secvURL.lastPathComponent) + ]) + } + self.videoSavingGate.hide() + }, receiveValue: { [weak self] value in + self?.encryptionProgress = value + }) + .store(in: &cancellables) + + } catch { + videoSavingGate.hide() + Logger.camera.error("Failed to encrypt video", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + } } diff --git a/SnapSafe/Screens/Camera/Services/CameraDeviceService.swift b/SnapSafe/Screens/Camera/Services/CameraDeviceService.swift index 6a64f30..41e4244 100644 --- a/SnapSafe/Screens/Camera/Services/CameraDeviceService.swift +++ b/SnapSafe/Screens/Camera/Services/CameraDeviceService.swift @@ -6,81 +6,94 @@ // import Foundation -import AVFoundation +@preconcurrency import AVFoundation import SwiftUI import Combine import Logging +// periphery:ignore all protocol CameraDeviceProviding: ObservableObject { + // periphery:ignore var session: AVCaptureSession { get } + // periphery:ignore var output: AVCapturePhotoOutput { get } + // periphery:ignore + var movieOutput: AVCaptureMovieFileOutput { get } + // periphery:ignore var currentDevice: AVCaptureDevice? { get } + // periphery:ignore var cameraPosition: AVCaptureDevice.Position { get } - - func setupCamera(for position: AVCaptureDevice.Position, lensType: CameraLensType) async + // periphery:ignore + func setupCamera(for position: AVCaptureDevice.Position) async + // periphery:ignore func switchCamera(to position: AVCaptureDevice.Position) async - func switchLensType(to lensType: CameraLensType) - func getUltraWideDevice() -> AVCaptureDevice? - func getWideAngleDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? + // periphery:ignore + func configureForMode(_ mode: CaptureMode) } +// periphery:ignore all @MainActor final class CameraDeviceService: ObservableObject, @preconcurrency CameraDeviceProviding { // MARK: - Published Properties - + @Published var session = AVCaptureSession() @Published var output = AVCapturePhotoOutput() + @Published var movieOutput = AVCaptureMovieFileOutput() @Published private(set) var currentDevice: AVCaptureDevice? @Published var cameraPosition: AVCaptureDevice.Position = .back - + @Published private(set) var currentCaptureMode: CaptureMode = .photo + // MARK: - Private Properties - - private var wideAngleDevice: AVCaptureDevice? - private var ultraWideDevice: AVCaptureDevice? + + private var audioInput: AVCaptureDeviceInput? private var isConfiguring = false - + private var isConfigured = false + + /// Audio-session configuration in effect before a recording, restored on + /// detach so playback behavior elsewhere in the app is unaffected. + private var previousAudioConfiguration: ( + category: AVAudioSession.Category, + mode: AVAudioSession.Mode, + options: AVAudioSession.CategoryOptions + )? + // MARK: - Initialization - + init() { // Initialize session configuration - session.sessionPreset = .photo + // Use .high preset to support both photo and video capture + session.sessionPreset = .high + // We configure the app's shared AVAudioSession ourselves (see + // attachAudioInput). Left automatic, the capture session reconfigures + // the audio session (category, mic selection, polar pattern) inside + // the commitConfiguration that adds/removes the mic input — and doing + // that against the RUNNING session stalls the video pipeline, so the + // preview flashes black at recording start and stop. session.automaticallyConfiguresApplicationAudioSession = false } // MARK: - Public Methods - func setupCamera(for position: AVCaptureDevice.Position, lensType: CameraLensType) async { + func setupCamera(for position: AVCaptureDevice.Position) async { + // Idempotent: the session's inputs/outputs only need to be built once. + // Re-running this removes the video input from a live session, so the + // preview momentarily has no feed and the black backdrop flashes through + // (e.g. on every return to the camera screen). Front/back changes go via + // switchCamera; app-background restarts via restartCameraSessionIfNeeded. + guard !isConfigured else { return } + session.beginConfiguration() - + // Clear existing inputs if let inputs = session.inputs as? [AVCaptureDeviceInput] { for input in inputs { session.removeInput(input) } } - - // Update device references - wideAngleDevice = getWideAngleDevice(position: position) - - if position == .back { - ultraWideDevice = getUltraWideDevice() - } else { - ultraWideDevice = nil - } - - // Select appropriate device based on lens type - var device: AVCaptureDevice? - let shouldUseUltraWide = lensType == .ultraWide && ultraWideDevice != nil && position == .back - - if shouldUseUltraWide { - device = ultraWideDevice - } else { - device = wideAngleDevice - } - - guard let device = device else { + + guard let device = camera(for: position) else { Logger.camera.error("Failed to get camera device", metadata: [ "position": .string(String(describing: position)) ]) @@ -94,9 +107,11 @@ final class CameraDeviceService: ObservableObject, @preconcurrency CameraDeviceP do { // Configure device with optimal settings try device.lockForConfiguration() - - device.videoZoomFactor = 1.0 - + + // Start at display 1.0x. On a virtual device with an ultra-wide + // constituent, that is the wide-lens switch-over factor, not 1.0. + device.videoZoomFactor = CameraZoomMapping(device: device).deviceZoom(forDisplayZoom: 1.0) + // Enable continuous auto modes if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus @@ -130,15 +145,22 @@ final class CameraDeviceService: ObservableObject, @preconcurrency CameraDeviceP if session.canAddInput(input) { session.addInput(input) } - - // Add photo output + + // Add photo output (first setup only; switchCamera re-runs setup + // with the output already attached) if session.canAddOutput(output) { session.addOutput(output) - configurePhotoOutputForMaxQuality() } - + configurePhotoOutputForMaxQuality(for: device) + + // Add movie output (keep both attached for smooth mode switching) + if session.canAddOutput(movieOutput) { + session.addOutput(movieOutput) + } + session.commitConfiguration() - + isConfigured = true + } catch { Logger.camera.error("Error setting up camera device", metadata: [ "error": .string(error.localizedDescription) @@ -156,53 +178,137 @@ final class CameraDeviceService: ObservableObject, @preconcurrency CameraDeviceP isConfiguring = true defer { isConfiguring = false } - - await setupCamera(for: position, lensType: .wideAngle) - + + // A camera switch must rebuild the session inputs for the new device, so + // clear the idempotency guard that setupCamera honors (it's there to skip + // a redundant rebuild on re-appear, not to block an actual switch). + isConfigured = false + await setupCamera(for: position) + if !session.isRunning { Task(priority: .userInitiated) { session.startRunning() } } } - - func switchLensType(to lensType: CameraLensType) { - guard !isConfiguring else { return } - guard cameraPosition == .back || lensType == .wideAngle else { return } - - isConfiguring = true - - Task(priority: .userInitiated) { [weak self] in - defer { - Task { @MainActor in - self?.isConfiguring = false - } - } - - await self?.setupCamera(for: self?.cameraPosition ?? .back, lensType: lensType) - - if let session = self?.session, !session.isRunning { - Task(priority: .userInitiated) { - session.startRunning() - } - } + + /// Picks the best camera for a position. For the back position this is the + /// most capable virtual device: its `videoZoomFactor` spans all constituent + /// lenses, and AVFoundation switches between them seamlessly — there is no + /// session reconfiguration when zoom crosses a lens boundary. + private func camera(for position: AVCaptureDevice.Position) -> AVCaptureDevice? { + if position == .back { + return AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) } + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) } - - func getUltraWideDevice() -> AVCaptureDevice? { - if let ultraWide = AVCaptureDevice.default(.builtInUltraWideCamera, for: .video, position: .back) { - return ultraWide + + // MARK: - Capture Mode Configuration + + func configureForMode(_ mode: CaptureMode) { + guard mode != currentCaptureMode else { return } + + // Switching modes no longer reconfigures the session. The movie output + // stays attached (added once in setupCamera) and the microphone is + // attached only while actually recording (see attachAudioInput). This + // keeps photo/video toggling free of session reconfiguration — which + // otherwise briefly stalls the live preview and makes it flicker — and + // keeps the system mic indicator off until recording begins. + currentCaptureMode = mode + Logger.camera.info("Configured camera for mode: \(String(describing: mode))") + } + + // MARK: - Audio Input Management + + /// Attaches the microphone input. Adding it activates the system mic + /// indicator, so this is called only while recording — not on entering + /// video mode. Wrapped in begin/commitConfiguration so the change applies + /// atomically just before recording starts. + func attachAudioInput() { + guard audioInput == nil else { return } + + guard let audioDevice = AVCaptureDevice.default(for: .audio) else { + Logger.camera.warning("No audio device available") + return + } + + // The capture session no longer auto-configures the audio session + // (see init), so put a recording-capable category in place BEFORE + // wiring the mic into the running capture graph — this keeps the + // commit below from touching audio routing, which is what made the + // preview flash black. The previous configuration is restored on + // detach. + let audioSession = AVAudioSession.sharedInstance() + previousAudioConfiguration = ( + audioSession.category, audioSession.mode, audioSession.categoryOptions + ) + do { + try audioSession.setCategory(.playAndRecord, mode: .videoRecording, options: [.defaultToSpeaker]) + } catch { + Logger.camera.warning("Failed to configure audio session for recording: \(error.localizedDescription)") + } + + do { + let input = try AVCaptureDeviceInput(device: audioDevice) + session.beginConfiguration() + if session.canAddInput(input) { + session.addInput(input) + audioInput = input + Logger.camera.debug("Added audio input") + } + session.commitConfiguration() + } catch { + Logger.camera.error("Failed to add audio input: \(error.localizedDescription)") } - return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) } - - func getWideAngleDevice(position: AVCaptureDevice.Position = .back) -> AVCaptureDevice? { - return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + + /// Detaches the microphone input once recording stops, releasing the mic + /// and clearing the system indicator. + func detachAudioInput() { + guard let audioInput = audioInput else { return } + + session.beginConfiguration() + if session.inputs.contains(audioInput) { + session.removeInput(audioInput) + } + session.commitConfiguration() + self.audioInput = nil + + // Hand the audio session back to its pre-recording configuration so + // the record-capable category doesn't re-engage the mic route when + // something else (e.g. the gallery player) activates audio later. + if let previous = previousAudioConfiguration { + previousAudioConfiguration = nil + do { + try AVAudioSession.sharedInstance().setCategory( + previous.category, mode: previous.mode, options: previous.options + ) + } catch { + Logger.camera.warning("Failed to restore audio session after recording: \(error.localizedDescription)") + } + } + + Logger.camera.debug("Removed audio input") } - + // MARK: - Private Methods - - private func configurePhotoOutputForMaxQuality() { + + private func configurePhotoOutputForMaxQuality(for device: AVCaptureDevice) { output.maxPhotoQualityPrioritization = .quality + // Allow the largest stills the active format supports instead of the + // session preset's video resolution — but only at the SAME aspect as + // the format, so captures keep matching the preview edge-for-edge. + let format = device.activeFormat + let feedDimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + let candidates = format.supportedMaxPhotoDimensions.map { (width: $0.width, height: $0.height) } + if let best = CameraPreviewLayout.largestDimensions( + matchingAspectOfWidth: feedDimensions.width, + height: feedDimensions.height, + in: candidates + ) { + output.maxPhotoDimensions = CMVideoDimensions(width: best.width, height: best.height) + } } } diff --git a/SnapSafe/Screens/Camera/Services/CameraFocusService.swift b/SnapSafe/Screens/Camera/Services/CameraFocusService.swift index 6feefd5..c123da0 100644 --- a/SnapSafe/Screens/Camera/Services/CameraFocusService.swift +++ b/SnapSafe/Screens/Camera/Services/CameraFocusService.swift @@ -6,25 +6,32 @@ // import Foundation -import AVFoundation +@preconcurrency import AVFoundation import SwiftUI import Combine import Logging +// periphery:ignore all @MainActor protocol FocusControlling: ObservableObject { + // periphery:ignore var focusIndicatorPoint: CGPoint? { get } + // periphery:ignore var showingFocusIndicator: Bool { get } - + // periphery:ignore func setupSubjectAreaChangeMonitoring(for device: AVCaptureDevice) + // periphery:ignore func adjustCameraSettings(at point: CGPoint, lockWhiteBalance: Bool, device: AVCaptureDevice?) + // periphery:ignore func showFocusIndicator(on viewPoint: CGPoint) + // periphery:ignore func startPeriodicFocusCheck(device: AVCaptureDevice?) + // periphery:ignore func stopPeriodicFocusCheck() - func normalizeGains(_ gains: AVCaptureDevice.WhiteBalanceGains, for device: AVCaptureDevice) -> AVCaptureDevice.WhiteBalanceGains } @MainActor +// periphery:ignore all final class CameraFocusService: ObservableObject, FocusControlling { // MARK: - Published Properties @@ -44,7 +51,7 @@ final class CameraFocusService: ObservableObject, FocusControlling { func setupSubjectAreaChangeMonitoring(for device: AVCaptureDevice) { // Remove existing observer if any if let currentDevice = currentDevice { - NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentDevice) + NotificationCenter.default.removeObserver(self, name: AVCaptureDevice.subjectAreaDidChangeNotification, object: currentDevice) } currentDevice = device @@ -52,7 +59,7 @@ final class CameraFocusService: ObservableObject, FocusControlling { NotificationCenter.default.addObserver( self, selector: #selector(subjectAreaDidChange), - name: .AVCaptureDeviceSubjectAreaDidChange, + name: AVCaptureDevice.subjectAreaDidChangeNotification, object: device ) } @@ -81,14 +88,14 @@ final class CameraFocusService: ObservableObject, FocusControlling { } // Handle white balance based on lock preference - if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { - if lockWhiteBalance { - device.whiteBalanceMode = .continuousAutoWhiteBalance - let currentWhiteBalanceGains = device.deviceWhiteBalanceGains - device.setWhiteBalanceModeLocked(with: currentWhiteBalanceGains, completionHandler: nil) - } else { - device.whiteBalanceMode = .continuousAutoWhiteBalance - } + if lockWhiteBalance && device.isWhiteBalanceModeSupported(.locked) { + // Lock at the current white balance. Do NOT use + // setWhiteBalanceModeLocked(with:) here: custom-gains locking is + // unsupported on virtual devices (dual-wide/triple camera) and + // throws NSInvalidArgumentException. + device.whiteBalanceMode = .locked + } else if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { + device.whiteBalanceMode = .continuousAutoWhiteBalance } device.unlockForConfiguration() @@ -96,7 +103,9 @@ final class CameraFocusService: ObservableObject, FocusControlling { // Schedule auto-focus reset with appropriate delay let resetDelay = lockWhiteBalance ? 8.0 : 3.0 focusResetTimer = Timer.scheduledTimer(withTimeInterval: resetDelay, repeats: false) { [weak self] _ in - self?.resetToAutoFocus(device: device) + Task { @MainActor [weak self] in + self?.resetToAutoFocus(device: device) + } } } catch { Logger.camera.error("Error adjusting camera settings", metadata: [ @@ -122,7 +131,9 @@ final class CameraFocusService: ObservableObject, FocusControlling { currentDevice = device focusCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in - self?.checkAndOptimizeFocus() + Task { @MainActor [weak self] in + self?.checkAndOptimizeFocus() + } } } @@ -131,17 +142,9 @@ final class CameraFocusService: ObservableObject, FocusControlling { focusCheckTimer = nil } - func normalizeGains(_ gains: AVCaptureDevice.WhiteBalanceGains, for device: AVCaptureDevice) -> AVCaptureDevice.WhiteBalanceGains { - var normalizedGains = gains - normalizedGains.redGain = max(1.0, min(gains.redGain, device.maxWhiteBalanceGain)) - normalizedGains.greenGain = max(1.0, min(gains.greenGain, device.maxWhiteBalanceGain)) - normalizedGains.blueGain = max(1.0, min(gains.blueGain, device.maxWhiteBalanceGain)) - return normalizedGains - } - // MARK: - Private Methods - @objc private func subjectAreaDidChange(notification: Notification) { + @objc private func subjectAreaDidChange(_: Notification) { refocusCamera() } @@ -166,7 +169,9 @@ final class CameraFocusService: ObservableObject, FocusControlling { device.unlockForConfiguration() focusResetTimer?.invalidate() focusResetTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in - self?.resetToAutoFocus(device: device) + Task { @MainActor [weak self] in + self?.resetToAutoFocus(device: device) + } } } catch { diff --git a/SnapSafe/Screens/Camera/Services/CameraPermissionService.swift b/SnapSafe/Screens/Camera/Services/CameraPermissionService.swift index 74b6eae..e92b7f5 100644 --- a/SnapSafe/Screens/Camera/Services/CameraPermissionService.swift +++ b/SnapSafe/Screens/Camera/Services/CameraPermissionService.swift @@ -11,14 +11,18 @@ import SwiftUI import Combine import Logging +// periphery:ignore all @MainActor protocol CameraPermissionProviding: ObservableObject { + // periphery:ignore var isPermissionGranted: Bool { get } - + // periphery:ignore func checkAndUpdatePermissions() async -> Bool + // periphery:ignore func updatePermissionState() } +// periphery:ignore all @MainActor final class CameraPermissionService: ObservableObject, CameraPermissionProviding { @@ -32,6 +36,7 @@ final class CameraPermissionService: ObservableObject, CameraPermissionProviding // MARK: - Debug/Simulator Detection + // periphery:ignore private var isRunningInSimulator: Bool { #if DEBUG && targetEnvironment(simulator) return true diff --git a/SnapSafe/Screens/Camera/Services/CameraPreviewLayout.swift b/SnapSafe/Screens/Camera/Services/CameraPreviewLayout.swift new file mode 100644 index 0000000..ce48141 --- /dev/null +++ b/SnapSafe/Screens/Camera/Services/CameraPreviewLayout.swift @@ -0,0 +1,55 @@ +// +// CameraPreviewLayout.swift +// SnapSafe +// + +import CoreGraphics + +internal enum CameraPreviewLayout { + /// Portrait width:height ratio for a capture format whose dimensions are + /// reported in landscape (e.g. 1920×1080 → 1080/1920 = 0.5625). + /// Falls back to 9:16 (the `.high` preset's ratio) for degenerate input. + internal static func portraitAspectRatio(formatWidth: Int32, formatHeight: Int32) -> CGFloat { + guard formatWidth > 0, formatHeight > 0 else { return 9.0 / 16.0 } + return CGFloat(formatHeight) / CGFloat(formatWidth) + } + + /// Largest centered rect of `aspectRatio` (width/height) fitting `size`, + /// preferring to fill the width. + internal static func containerSize(for size: CGSize, aspectRatio: CGFloat) -> CGSize { + let width = size.width + let height = width / aspectRatio + if height > size.height { + return CGSize(width: size.height * aspectRatio, height: size.height) + } + return CGSize(width: width, height: height) + } + + /// The largest candidate (by pixel area) whose aspect ratio matches the + /// reference dimensions within `tolerance`. Falls back to the largest + /// candidate overall when none match, so callers always get usable + /// dimensions. Returns nil only for an empty candidate list. + /// + /// Used to pick `maxPhotoDimensions`: stills must keep the SAME aspect as + /// the active video format, otherwise captures would show more of the + /// scene than the preview (breaking preview == capture). + internal static func largestDimensions( + matchingAspectOfWidth referenceWidth: Int32, + height referenceHeight: Int32, + in candidates: [(width: Int32, height: Int32)], + tolerance: CGFloat = 0.01 + ) -> (width: Int32, height: Int32)? { + let byArea: ((width: Int32, height: Int32), (width: Int32, height: Int32)) -> Bool = { + Int64($0.width) * Int64($0.height) < Int64($1.width) * Int64($1.height) + } + guard referenceWidth > 0, referenceHeight > 0 else { return candidates.max(by: byArea) } + + let referenceAspect = CGFloat(referenceWidth) / CGFloat(referenceHeight) + let matching = candidates.filter { candidate in + guard candidate.width > 0, candidate.height > 0 else { return false } + let aspect = CGFloat(candidate.width) / CGFloat(candidate.height) + return abs(aspect - referenceAspect) / referenceAspect <= tolerance + } + return (matching.isEmpty ? candidates : matching).max(by: byArea) + } +} diff --git a/SnapSafe/Screens/Camera/Services/CameraZoomMapping.swift b/SnapSafe/Screens/Camera/Services/CameraZoomMapping.swift new file mode 100644 index 0000000..7688df6 --- /dev/null +++ b/SnapSafe/Screens/Camera/Services/CameraZoomMapping.swift @@ -0,0 +1,64 @@ +// +// CameraZoomMapping.swift +// SnapSafe +// + +import AVFoundation +import Foundation + +/// Maps user-facing ("display") zoom factors to the virtual camera device's +/// `videoZoomFactor` space. +/// +/// On a virtual device (`builtInDualWideCamera` / `builtInTripleCamera`), +/// `videoZoomFactor` 1.0 is the ultra-wide lens at full FOV. The wide lens +/// engages at the first entry of `virtualDeviceSwitchOverVideoZoomFactors` +/// (typically 2.0) — that is what users see as "1.0x". +struct CameraZoomMapping: Equatable { + let wideSwitchOverFactor: CGFloat + let minDisplayZoom: CGFloat + let maxDisplayZoom: CGFloat + + init(switchOverFactors: [CGFloat], maxDeviceZoom: CGFloat, displayZoomCap: CGFloat = 10.0) { + // A switch-over <= 1.0 (none, zero, negative) means there is no + // ultra-wide constituent: fall back to the identity mapping. + let firstSwitchOver = switchOverFactors.first ?? 1.0 + let wide = firstSwitchOver > 1.0 ? firstSwitchOver : 1.0 + self.wideSwitchOverFactor = wide + self.minDisplayZoom = 1.0 / wide + self.maxDisplayZoom = min(maxDeviceZoom / wide, displayZoomCap) + } + + /// Converts a display zoom (what the UI shows) to the device's + /// `videoZoomFactor`, clamped to the device's valid range. + func deviceZoom(forDisplayZoom displayZoom: CGFloat) -> CGFloat { + clampedDisplayZoom(displayZoom) * wideSwitchOverFactor + } + + /// Converts a `videoZoomFactor` back to the display zoom shown in the UI. + func displayZoom(forDeviceZoom deviceZoom: CGFloat) -> CGFloat { + deviceZoom / wideSwitchOverFactor + } + + /// Clamps a display zoom to the displayable range. + func clampedDisplayZoom(_ displayZoom: CGFloat) -> CGFloat { + min(max(displayZoom, minDisplayZoom), maxDisplayZoom) + } +} + +extension CameraZoomMapping { + /// Derives the mapping from a capture device. Only a virtual device whose + /// widest constituent is the ultra-wide lens places display 1.0x at the + /// first switch-over factor; every other device maps identically. + init(device: AVCaptureDevice) { + let switchOvers: [CGFloat] + if device.constituentDevices.first?.deviceType == .builtInUltraWideCamera { + switchOvers = device.virtualDeviceSwitchOverVideoZoomFactors.map { CGFloat(truncating: $0) } + } else { + switchOvers = [] + } + self.init( + switchOverFactors: switchOvers, + maxDeviceZoom: device.activeFormat.videoMaxZoomFactor + ) + } +} diff --git a/SnapSafe/Screens/Camera/Services/CameraZoomService.swift b/SnapSafe/Screens/Camera/Services/CameraZoomService.swift index d543283..b2c62d5 100644 --- a/SnapSafe/Screens/Camera/Services/CameraZoomService.swift +++ b/SnapSafe/Screens/Camera/Services/CameraZoomService.swift @@ -11,211 +11,147 @@ import SwiftUI import Combine import Logging +// periphery:ignore all @MainActor protocol ZoomControlling: ObservableObject { + // periphery:ignore var zoomFactor: CGFloat { get } + // periphery:ignore var minZoom: CGFloat { get } + // periphery:ignore var maxZoom: CGFloat { get } - var currentLensType: CameraLensType { get } + // periphery:ignore var zoomDetents: [CGFloat] { get } - + // periphery:ignore func updateZoomLimits(for device: AVCaptureDevice?) + // periphery:ignore func zoom(factor: CGFloat, device: AVCaptureDevice?) async - func handlePinchGesture(scale: CGFloat, initialScale: CGFloat?, device: AVCaptureDevice?, onLensSwitch: @escaping (CameraLensType) -> Void) + // periphery:ignore + func handlePinchGesture(scale: CGFloat, initialScale: CGFloat?, device: AVCaptureDevice?) + // periphery:ignore func resetZoomLevel(device: AVCaptureDevice?) + // periphery:ignore func snapToNearestDetent(threshold: CGFloat) async } +/// Controls zoom on a single (virtual) capture device. +/// +/// The session runs one virtual device (dual-wide/triple camera) whose +/// `videoZoomFactor` spans every constituent lens, so crossing 1.0x is a +/// seamless, system-managed lens switch — no session rebuild and no manual +/// lens bookkeeping. `CameraZoomMapping` converts between the user-facing +/// display zoom (0.5x, 1x, 2x…) and the device's zoom-factor space. +// periphery:ignore all @MainActor final class CameraZoomService: ObservableObject, ZoomControlling { - + // MARK: - Published Properties - + + /// Display zoom — what the UI shows (0.5x … 10x). @Published var zoomFactor: CGFloat = 1.0 - @Published var minZoom: CGFloat = 0.5 + @Published var minZoom: CGFloat = 1.0 @Published var maxZoom: CGFloat = 10.0 - @Published var currentLensType: CameraLensType = .wideAngle // MARK: - Public Properties + // periphery:ignore let zoomDetents: [CGFloat] = [0.5, 1.0, 2.0, 3.0, 5.0, 10.0] // MARK: - Private Properties + private var mapping = CameraZoomMapping(switchOverFactors: [], maxDeviceZoom: 10.0) private var initialZoom: CGFloat = 1.0 private weak var currentDevice: AVCaptureDevice? // MARK: - Public Methods - + func updateZoomLimits(for device: AVCaptureDevice?) { - guard let device = device else { return } + guard let device else { return } currentDevice = device - let minZoomValue: CGFloat = 0.5 - let maxZoomValue = min(device.activeFormat.videoMaxZoomFactor, 10.0) - let defaultZoomValue: CGFloat = 1.0 + mapping = CameraZoomMapping(device: device) + minZoom = mapping.minDisplayZoom + maxZoom = mapping.maxDisplayZoom - minZoom = minZoomValue - maxZoom = maxZoomValue - zoomFactor = defaultZoomValue + // Always start at display 1.0x (the wide lens). A virtual device's + // default `videoZoomFactor` of 1.0 is the ultra-wide lens, which maps + // to display 0.5x — so reflecting the device default would come up + // zoomed out. Actively position the device at display 1.0x instead. + let initialDisplayZoom = mapping.clampedDisplayZoom(1.0) + do { + try device.lockForConfiguration() + device.videoZoomFactor = mapping.deviceZoom(forDisplayZoom: initialDisplayZoom) + device.unlockForConfiguration() + } catch { + Logger.camera.error("Error setting initial zoom", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + zoomFactor = initialDisplayZoom } - - // Smooth zoom with lens-specific adjustments and auto mode restoration + func zoom(factor: CGFloat, device: AVCaptureDevice?) async { - guard let device = device else { return } - + guard let device else { return } + do { try device.lockForConfiguration() - - // Restore auto modes during zoom - if device.isExposureModeSupported(.continuousAutoExposure) && device.exposureMode != .continuousAutoExposure { - device.exposureMode = .continuousAutoExposure - } - - if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) && device.whiteBalanceMode != .continuousAutoWhiteBalance { - device.whiteBalanceMode = .continuousAutoWhiteBalance - } - - var newZoomFactor = factor - - if currentLensType == .ultraWide { - // Map ultra-wide zoom range (0.5x user-facing to device zoom) - newZoomFactor = max(0.5, min(newZoomFactor, maxZoom)) - let deviceZoomFactor = (newZoomFactor / 0.5) - let limitedDeviceZoom = min(deviceZoomFactor, device.activeFormat.videoMaxZoomFactor) - let currentZoom = device.videoZoomFactor - let interpolationFactor: CGFloat = 0.3 - let smoothedZoom = currentZoom + (limitedDeviceZoom - currentZoom) * interpolationFactor - - device.videoZoomFactor = smoothedZoom - let userFacingZoom = max(0.5, min(newZoomFactor, maxZoom)) - - await MainActor.run { - self.zoomFactor = userFacingZoom - } - } else { - // Wide-angle zoom with smooth interpolation - newZoomFactor = max(1.0, min(newZoomFactor, maxZoom)) - let currentZoom = device.videoZoomFactor - let interpolationFactor: CGFloat = 0.3 - let smoothedZoom = currentZoom + (newZoomFactor - currentZoom) * interpolationFactor - - device.videoZoomFactor = smoothedZoom - - await MainActor.run { - self.zoomFactor = smoothedZoom - } - } - + + // Zooming after tap-to-focus should release any locked modes. + restoreAutoModes(on: device) + + let displayZoom = mapping.clampedDisplayZoom(factor) + device.videoZoomFactor = mapping.deviceZoom(forDisplayZoom: displayZoom) device.unlockForConfiguration() + + zoomFactor = displayZoom } catch { Logger.camera.error("Error setting zoom", metadata: [ "error": .string(error.localizedDescription) ]) } } - - // Handle pinch gestures with automatic lens switching and smooth zoom - func handlePinchGesture(scale: CGFloat, initialScale: CGFloat? = nil, device: AVCaptureDevice?, onLensSwitch: @escaping (CameraLensType) -> Void) { + + func handlePinchGesture(scale: CGFloat, initialScale: CGFloat? = nil, device: AVCaptureDevice?) { if initialScale != nil { initialZoom = zoomFactor } - + let zoomSensitivity: CGFloat = 0.5 let zoomDelta = pow(scale, zoomSensitivity) - 1.0 let newZoomFactor = initialZoom + (zoomDelta * (maxZoom - minZoom)) - - // Determine lens switching thresholds - // Use ultra-wide for anything below 1.0, wide-angle for 1.0 and above - // Only back camera supports ultra-wide - let shouldUseUltraWide = newZoomFactor < 1.0 && device != nil && device?.position == .back - let shouldUseWideAngle = newZoomFactor >= 1.0 - - if shouldUseUltraWide && currentLensType != .ultraWide { - // Prepare auto modes for smooth lens transition - Logger.camera.info("Switching to ultra-wide lens at zoom factor: \(newZoomFactor)") - prepareAutoModesForTransition(device: device) - currentLensType = .ultraWide - - // Store the target zoom before switching - let targetZoom = newZoomFactor - - onLensSwitch(.ultraWide) - - // After lens switch completes, apply the target zoom - Task { - try? await Task.sleep(for: .milliseconds(150)) - await zoom(factor: targetZoom, device: device) - } - } else if shouldUseWideAngle && currentLensType != .wideAngle { - Logger.camera.info("Switching to wide-angle lens at zoom factor: \(newZoomFactor)") - prepareAutoModesForTransition(device: device) - currentLensType = .wideAngle - - // Clamp to wide-angle minimum (1.0x) - let targetZoom = max(1.0, newZoomFactor) - Logger.camera.info("Clamping zoom to \(targetZoom) for wide-angle lens") - onLensSwitch(.wideAngle) - - // After lens switch completes, apply the target zoom - Task { - try? await Task.sleep(for: .milliseconds(150)) - await zoom(factor: targetZoom, device: device) - } - } else { - // Apply zoom with auto mode restoration - restoreAutoModes(device: device) - - Task { - await zoom(factor: newZoomFactor, device: device) - } + Task { + await zoom(factor: newZoomFactor, device: device) } } - - // Reset zoom level to 1.0 (called when app comes from background) + + /// Reset to display 1.0x (mode switches, returning from background). func resetZoomLevel(device: AVCaptureDevice?) { - guard let device = device else { return } - + guard let device else { return } + Task(priority: .userInitiated) { - do { - try device.lockForConfiguration() - device.videoZoomFactor = 1.0 - device.unlockForConfiguration() - - await MainActor.run { - self.zoomFactor = 1.0 - } - } catch { - Logger.camera.error("Error resetting zoom level", metadata: [ - "error": .string(error.localizedDescription) - ]) - } + await zoom(factor: 1.0, device: device) } } - - func updateLensType(_ lensType: CameraLensType) { - currentLensType = lensType - } - + + // periphery:ignore func updateZoomForSimulator() { minZoom = 0.5 maxZoom = 10.0 zoomFactor = 1.0 } + // periphery:ignore func snapToNearestDetent(threshold: CGFloat) async { let currentZoom = zoomFactor var closestLevel = currentZoom var minDistance = CGFloat.greatestFiniteMagnitude - for level in zoomDetents { - if level >= minZoom && level <= maxZoom { - let distance = abs(currentZoom - level) - if distance < minDistance && distance <= threshold { - minDistance = distance - closestLevel = level - } + for level in zoomDetents where level >= minZoom && level <= maxZoom { + let distance = abs(currentZoom - level) + if distance < minDistance && distance <= threshold { + minDistance = distance + closestLevel = level } } @@ -225,45 +161,15 @@ final class CameraZoomService: ObservableObject, ZoomControlling { } // MARK: - Private Methods - - private func prepareAutoModesForTransition(device: AVCaptureDevice?) { - guard let device = device else { return } - - do { - try device.lockForConfiguration() - - if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { - device.whiteBalanceMode = .continuousAutoWhiteBalance - } - if device.isExposureModeSupported(.continuousAutoExposure) { - device.exposureMode = .continuousAutoExposure - } - - device.unlockForConfiguration() - } catch { - Logger.camera.error("Error preparing auto modes before lens switch", metadata: [ - "error": .string(error.localizedDescription) - ]) + + /// Device must already be locked for configuration. + private func restoreAutoModes(on device: AVCaptureDevice) { + if device.isExposureModeSupported(.continuousAutoExposure) && device.exposureMode != .continuousAutoExposure { + device.exposureMode = .continuousAutoExposure } - } - - private func restoreAutoModes(device: AVCaptureDevice?) { - guard let device = device else { return } - - do { - try device.lockForConfiguration() - - if device.isExposureModeSupported(.continuousAutoExposure) && device.exposureMode != .continuousAutoExposure { - device.exposureMode = .continuousAutoExposure - } - - if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) && device.whiteBalanceMode != .continuousAutoWhiteBalance { - device.whiteBalanceMode = .continuousAutoWhiteBalance - } - - device.unlockForConfiguration() - } catch { - // Ignore errors here, it's just optimization + + if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) && device.whiteBalanceMode != .continuousAutoWhiteBalance { + device.whiteBalanceMode = .continuousAutoWhiteBalance } } } diff --git a/SnapSafe/Screens/Camera/Services/PhotoCaptureService.swift b/SnapSafe/Screens/Camera/Services/PhotoCaptureService.swift index ac445f8..2f1902f 100644 --- a/SnapSafe/Screens/Camera/Services/PhotoCaptureService.swift +++ b/SnapSafe/Screens/Camera/Services/PhotoCaptureService.swift @@ -12,15 +12,20 @@ import Combine import Logging import FactoryKit +// periphery:ignore all @MainActor protocol PhotoCapturing: ObservableObject { + // periphery:ignore var recentImage: UIImage? { get } - + // periphery:ignore func capturePhoto(flashMode: AVCaptureDevice.FlashMode, cameraPosition: AVCaptureDevice.Position, output: AVCapturePhotoOutput, preview: AVCaptureVideoPreviewLayer?, session: AVCaptureSession) + // periphery:ignore func captureMockPhoto(cameraPosition: AVCaptureDevice.Position) async + // periphery:ignore func saveMockPhoto(_ imageData: Data) async } +// periphery:ignore all @MainActor final class PhotoCaptureService: NSObject, ObservableObject, PhotoCapturing { @@ -44,7 +49,7 @@ final class PhotoCaptureService: NSObject, ObservableObject, PhotoCapturing { func capturePhoto(flashMode: AVCaptureDevice.FlashMode, cameraPosition: AVCaptureDevice.Position, output: AVCapturePhotoOutput, preview: AVCaptureVideoPreviewLayer?, session: AVCaptureSession) { isSavingPhoto = true - let photoSettings = createAdvancedPhotoSettings() + let photoSettings = createAdvancedPhotoSettings(for: output) // Configure flash based on camera position if cameraPosition == .back { @@ -161,12 +166,14 @@ final class PhotoCaptureService: NSObject, ObservableObject, PhotoCapturing { // MARK: - Private Methods - private func createAdvancedPhotoSettings() -> AVCapturePhotoSettings { + private func createAdvancedPhotoSettings(for output: AVCapturePhotoOutput) -> AVCapturePhotoSettings { let settings = AVCapturePhotoSettings() settings.photoQualityPrioritization = .quality + settings.maxPhotoDimensions = output.maxPhotoDimensions return settings } + // periphery:ignore private func fixImageOrientation(_ image: UIImage) -> UIImage { if image.imageOrientation == .up { return image diff --git a/SnapSafe/Screens/Camera/Services/VideoCaptureService.swift b/SnapSafe/Screens/Camera/Services/VideoCaptureService.swift new file mode 100644 index 0000000..92c4b21 --- /dev/null +++ b/SnapSafe/Screens/Camera/Services/VideoCaptureService.swift @@ -0,0 +1,210 @@ +// +// VideoCaptureService.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import AVFoundation +import Combine +import CoreLocation +import FactoryKit +import Logging + +// periphery:ignore all +@MainActor +protocol VideoCapturing: ObservableObject { + // periphery:ignore + var isRecording: Bool { get } + // periphery:ignore + var recordingDurationMs: Int64 { get } + // periphery:ignore + func startRecording(session: AVCaptureSession, movieOutput: AVCaptureMovieFileOutput, preview: AVCaptureVideoPreviewLayer?) -> URL? + // periphery:ignore + func stopRecording() +} + +// periphery:ignore all +@MainActor +final class VideoCaptureService: NSObject, ObservableObject, VideoCapturing { + + // MARK: - Published Properties + + @Published var isRecording: Bool = false + @Published var recordingDurationMs: Int64 = 0 + + /// Called when a recording finishes successfully, with the output file URL. + var onRecordingFinished: ((URL) -> Void)? + + /// Called when a recording finalizes with an error (no file to encrypt). + var onRecordingFailed: (() -> Void)? + + /// Called once a recording has fully finalized (success or failure), after + /// the file output is done writing. Use this to release resources tied to + /// the recording, e.g. detaching the microphone input. + var onRecordingStopped: (() -> Void)? + + // MARK: - Properties + + private var activeMovieOutput: AVCaptureMovieFileOutput? + private var durationTimer: Timer? + private var recordingStartTime: Date? + + // MARK: - Dependencies + + @Injected(\.locationRepository) + private var locationRepository: LocationRepository + + // MARK: - Directory Management + + private func getVideosDirectory() -> URL { + let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + var videosDir = appSupportPath.appendingPathComponent("videos") + + // Create directory and exclude from backup + do { + try FileManager.default.createDirectory(at: videosDir, withIntermediateDirectories: true, attributes: nil) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try videosDir.setResourceValues(resourceValues) + } catch { + Logger.camera.error("Failed to setup videos directory: \(error)") + } + + return videosDir + } + + // MARK: - Public Methods + + func startRecording(session: AVCaptureSession, movieOutput: AVCaptureMovieFileOutput, preview: AVCaptureVideoPreviewLayer?) -> URL? { + guard !isRecording else { + Logger.camera.warning("Recording already in progress") + return nil + } + + // Ensure movie output is added to session + if !session.outputs.contains(movieOutput) { + Logger.camera.error("Movie output not added to session") + return nil + } + + // Store reference to the movie output for stopRecording + activeMovieOutput = movieOutput + + // Create output file + let videosDir = getVideosDirectory() + let timestamp = DateFormatter.videoTimestamp.string(from: Date()) + let filename = "video_\(timestamp).mov" + let outputURL = videosDir.appendingPathComponent(filename) + + // Remove existing file if present + try? FileManager.default.removeItem(at: outputURL) + + // Configure video orientation + if let connection = movieOutput.connection(with: .video) { + // Get proper rotation for video + if let deviceInput = session.inputs + .compactMap({ $0 as? AVCaptureDeviceInput }) + .first(where: { $0.device.hasMediaType(.video) }) { + + let rotationCoordinator = AVCaptureDevice.RotationCoordinator( + device: deviceInput.device, + previewLayer: preview + ) + connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture + } + } + + // Embed capture location (single point, sampled at record-start, matching + // how photos sample at shutter) and creation date into the QuickTime header. + // AVCaptureMovieFileOutput writes these during recording; no post pass. + let location = locationRepository.lastLocation + movieOutput.metadata = AVMetadataItemFactory.makeCaptureItems(location: location, date: Date()) + + // Start recording + movieOutput.startRecording(to: outputURL, recordingDelegate: self) + + Logger.camera.info("Starting video recording to: \(filename)") + return outputURL + } + + func stopRecording() { + guard isRecording, let movieOutput = activeMovieOutput else { + Logger.camera.warning("No recording in progress to stop") + return + } + + movieOutput.stopRecording() + Logger.camera.info("Stopping video recording") + } + + // MARK: - Private Methods + + private func startDurationTimer() { + recordingDurationMs = 0 + recordingStartTime = Date() + + durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self = self, let startTime = self.recordingStartTime else { return } + let elapsed = Date().timeIntervalSince(startTime) + self.recordingDurationMs = Int64(elapsed * 1000) + } + } + } + + private func stopDurationTimer() { + durationTimer?.invalidate() + durationTimer = nil + recordingStartTime = nil + } +} + +// MARK: - AVCaptureFileOutputRecordingDelegate + +extension VideoCaptureService: AVCaptureFileOutputRecordingDelegate { + + nonisolated func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) { + Task { @MainActor in + self.isRecording = true + self.startDurationTimer() + Logger.camera.info("Video recording started") + } + } + + nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + Task { @MainActor in + self.isRecording = false + self.stopDurationTimer() + self.activeMovieOutput = nil + + if let error = error { + Logger.camera.error("Video recording error: \(error.localizedDescription)") + // Clean up failed recording + try? FileManager.default.removeItem(at: outputFileURL) + self.onRecordingFailed?() + } else { + Logger.camera.info("Video recording completed successfully", metadata: [ + "file": .string(outputFileURL.lastPathComponent), + "durationMs": .stringConvertible(self.recordingDurationMs) + ]) + self.onRecordingFinished?(outputFileURL) + } + + // File output has finished writing; safe to release the mic now. + self.onRecordingStopped?() + } + } +} + +// MARK: - DateFormatter Extension + +private extension DateFormatter { + static let videoTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} diff --git a/SnapSafe/Screens/ContentView.swift b/SnapSafe/Screens/ContentView.swift index aa44e3f..6d4722b 100644 --- a/SnapSafe/Screens/ContentView.swift +++ b/SnapSafe/Screens/ContentView.swift @@ -7,6 +7,7 @@ import AVFoundation import CoreGraphics +import CryptoKit import ImageIO import PhotosUI import SwiftUI @@ -20,8 +21,6 @@ extension Notification.Name { struct ContentView: View { @StateObject private var viewModel = ContentViewModel() - @InjectedObject(\.locationRepository) private var locationManager: LocationRepository - @EnvironmentObject private var nav: AppNavigationState var body: some View { @@ -65,16 +64,27 @@ struct ContentView: View { // MARK: - Navigation Methods private func navigateToRootDestination() { - // Clear current navigation path and navigate to root destination + // Clear current navigation path and navigate to root destination. nav.clearNavigationStack() - nav.navigate(to: currentRootDestination) + if let destination = currentRootDestination { + nav.navigate(to: destination) + } } - - private var currentRootDestination: AppDestination { + + private var currentRootDestination: AppDestination? { + #if DEBUG + if CommandLine.arguments.contains("-SkipAuthentication") { + return .camera + } + #endif if viewModel.hasCompletedIntro == false { return .pinSetup } else if !viewModel.isAuthenticated { - return .pinVerification + // Authentication is owned entirely by the security overlay + // (.securityManaged()). Leave the nav stack at its empty Color.clear + // root so we do NOT mount a second, competing PIN screen underneath + // the overlay — two PIN text fields deadlock first responder. + return nil } else { return .camera } @@ -84,8 +94,10 @@ struct ContentView: View { private func shouldHideNavigationBar(for destination: AppDestination) -> Bool { switch destination { - case .gallery, .photoObfuscation, .settings: + case .gallery, .photoObfuscation, .settings, .videoExportTest, .photoInfo, .videoInfo: return false + case .videoPlayer: + return true default: return true } @@ -108,19 +120,39 @@ struct ContentView: View { PINVerificationView() case .camera: CameraContainerView() - case .photoDetail(let allPhotos, let initialIndex): + case .photoDetail(let allMedia, let initialIndex): EnhancedPhotoDetailView( - allPhotos: allPhotos, + allMedia: allMedia, initialIndex: initialIndex, onDelete: nil, onDismiss: nil ) case .photoInfo(let photoDef): ImageInfoView(photoDef: photoDef) + case .videoInfo(let videoDef): + VideoInfoView(videoDef: videoDef) case .photoObfuscation(let photoDef): PhotoObfuscationView(photoDef: photoDef, navigator: nav) case .poisonPillSetupWizard: PoisonPillSetupWizardView() + case .videoPlayer(let videoDef, let keyData): + VideoPlayerView( + videoDef: videoDef, + encryptionKey: keyData.map { SymmetricKey(data: $0) } + ) + case .videoExportTest: + // Dev-only screen; the view type is compiled in Debug builds only. + #if DEBUG + if #available(iOS 18.0, *) { + VideoExportTestView() + } else { + Text("Video Export Testing requires iOS 18+") + .font(.title2) + .foregroundStyle(.secondary) + } + #else + EmptyView() + #endif } } } diff --git a/SnapSafe/Screens/ContentViewModel.swift b/SnapSafe/Screens/ContentViewModel.swift index d341da5..6edb53d 100644 --- a/SnapSafe/Screens/ContentViewModel.swift +++ b/SnapSafe/Screens/ContentViewModel.swift @@ -24,16 +24,14 @@ final class ContentViewModel: ObservableObject { @Injected(\.authorizationRepository) private var authorizationRepository: AuthorizationRepository - @Injected(\.locationRepository) - private var locationManager: LocationRepository - - private let screenCaptureManager = ScreenCaptureManager.shared - private var cancellables = Set() // MARK: - Initialization init() { + // Seed synchronously so navigateToRootDestination() in onAppear sees the + // correct value before the Combine pipeline's async main-thread hop fires. + self.hasCompletedIntro = settings.hasCompletedIntroValue setupObservers() } diff --git a/SnapSafe/Screens/Gallery/MixedMediaGalleryViewModel.swift b/SnapSafe/Screens/Gallery/MixedMediaGalleryViewModel.swift new file mode 100644 index 0000000..250e5e2 --- /dev/null +++ b/SnapSafe/Screens/Gallery/MixedMediaGalleryViewModel.swift @@ -0,0 +1,660 @@ +// +// MixedMediaGalleryViewModel.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import PhotosUI +import SwiftUI +import Combine +import FactoryKit +import Logging +import CryptoKit +import CoreTransferable +import UniformTypeIdentifiers + +/// A movie loaded from the photo library, copied to a temporary location we own. +struct ImportedMovie: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(contentType: .movie) { movie in + SentTransferredFile(movie.url) + } importing: { received in + let ext = received.file.pathExtension.isEmpty ? "mov" : received.file.pathExtension + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(ext) + try? FileManager.default.removeItem(at: temp) + try FileManager.default.copyItem(at: received.file, to: temp) + return ImportedMovie(url: temp) + } + } +} + +/// Gallery selection modes. +enum SelectionMode { + case none + case share + case delete + case decoy +} + +/// Enhanced gallery view model that supports both photos and videos. +@MainActor +final class MixedMediaGalleryViewModel: ObservableObject { + + // MARK: - Published Properties + + @Published var mediaItems: [GalleryMediaItem] = [] + @Published var selectedMediaItem: GalleryMediaItem? + @Published var selectionMode: SelectionMode = .none + @Published var selectedMediaIds = Set() + @Published var showDeleteConfirmation = false + @Published var pickerItems: [PhotosPickerItem] = [] + @Published var isImporting: Bool = false + @Published var importProgress: Float = 0 + @Published var photoThumbnails: [String: UIImage] = [:] // keyed by photoName + @Published var videoThumbnails: [String: UIImage] = [:] // keyed by videoName + + // Decoy support + var isSelecting: Bool { selectionMode != .none } + var isSelectingDecoys: Bool { selectionMode == .decoy } + // Selection cap for batch decoy mode. Tied to the single source of truth so + // the UI cap can never drift from what the use cases actually enforce. + let maxDecoys = SecureImageRepository.maxDecoyItems + @Published var showDecoyLimitWarning: Bool = false + @Published var showDecoyConfirmation: Bool = false + @Published var isPoisonPillConfigured: Bool = false + + /// Set while `saveDecoySelections()` is running. Decoy videos are re-encrypted + /// with the poison-pill key, which can take a while for large videos. + @Published var isSavingDecoys: Bool = false + @Published var decoySaveTotal: Int = 0 + @Published var decoySaveCompleted: Int = 0 + + // MARK: - Dependencies + + @Injected(\.secureImageRepository) + private var secureImageRepository: SecureImageRepository + + @Injected(\.thumbnailCache) + private var thumbnailCache: ThumbnailCache + + @Injected(\.videoEncryptionService) + private var videoEncryptionService: VideoEncryptionService + + @Injected(\.clock) + private var clock: Clock + + @Injected(\.addDecoyPhotoUseCase) + private var addDecoyPhotoUseCase: AddDecoyPhotoUseCase + + @Injected(\.removeDecoyPhotoUseCase) + private var removeDecoyPhotoUseCase: RemoveDecoyPhotoUseCase + + @Injected(\.addDecoyVideoUseCase) + private var addDecoyVideoUseCase: AddDecoyVideoUseCase + + @Injected(\.prepareForSharingUseCase) + private var prepareForSharingUseCase: PrepareForSharingUseCase + + @Injected(\.authorizationRepository) + private var authorizationRepository: AuthorizationRepository + + @Injected(\.pinRepository) + private var pinRepository: PinRepository + + @Injected(\.encryptionScheme) + private var encryptionScheme: EncryptionScheme + + private var cancellables = Set() + private weak var currentActivityController: UIActivityViewController? + private var encryptionKey: SymmetricKey? + + // MARK: - Initialization + + init(selectingDecoys: Bool = false) { + self.selectionMode = selectingDecoys ? .decoy : .none + + setupObservers() + } + + // MARK: - View Lifecycle + + func onAppear() { + Task { + // Load encryption key first so videos get the key attached + do { + let keyData = try await encryptionScheme.getDerivedKey() + encryptionKey = SymmetricKey(data: keyData) + } catch { + Logger.storage.error("Failed to get encryption key for gallery", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + // Now load media items (uses encryptionKey for video items) + loadMediaItems() + } + loadPoisonPillConfiguration() + } + + private func loadPoisonPillConfiguration() { + Task { + let hasPoisonPill = await pinRepository.hasPoisonPillPin() + await MainActor.run { + isPoisonPillConfigured = hasPoisonPill + } + } + } + + // MARK: - Computed Properties + + var hasSelection: Bool { + !selectedMediaIds.isEmpty + } + + /// Whether the given media item is currently marked as a decoy. + private func isItemDecoy(_ item: GalleryMediaItem) async -> Bool { + if let photoDef = item.photoDef { + return await secureImageRepository.isDecoyPhoto(photoDef) + } else if let videoDef = item.videoDef { + return await secureImageRepository.isDecoyVideo(videoDef) + } + return false + } + + // MARK: - Thumbnails + + /// Loads the thumbnail for a photo, preferring the in-memory cache and falling + /// back to a disk decrypt. Publishes the result so observing cells update. + func loadThumbnail(for photo: PhotoDef) async { + let key = photo.photoName + if photoThumbnails[key] != nil { return } + if let cached = thumbnailCache.getThumbnail(photo) { + photoThumbnails[key] = cached + return + } + guard let data = await secureImageRepository.readThumbnail(photo), + let image = UIImage(data: data) else { return } + thumbnailCache.putThumbnail(photo, image) + photoThumbnails[key] = image + } + + /// Loads the thumbnail for a video, preferring the in-memory cache and falling + /// back to a disk decrypt. Publishes the result so observing cells update. + func loadVideoThumbnail(for video: VideoDef) async { + let key = video.videoName + if videoThumbnails[key] != nil { return } + if let cached = thumbnailCache.getVideoThumbnail(key) { + videoThumbnails[key] = cached + return + } + guard let data = await secureImageRepository.readVideoThumbnail(video), + let image = UIImage(data: data) else { return } + thumbnailCache.putVideoThumbnail(key, image) + videoThumbnails[key] = image + } + + /// Drops a photo's thumbnail from both the in-memory cache and the published + /// dict. Called on delete so a stale decrypted bitmap isn't retained or served + /// for a later item that reuses the same name. + private func invalidatePhotoThumbnail(_ photo: PhotoDef) { + thumbnailCache.evictThumbnail(photo) + photoThumbnails.removeValue(forKey: photo.photoName) + } + + /// Drops a video's thumbnail from both the in-memory cache and the published dict. + private func invalidateVideoThumbnail(named name: String) { + thumbnailCache.evictVideoThumbnail(name) + videoThumbnails.removeValue(forKey: name) + } + + /// Whether the given photo is currently marked as a decoy. + func isDecoyPhoto(_ photo: PhotoDef) async -> Bool { + await secureImageRepository.isDecoyPhoto(photo) + } + + /// Whether the given video is currently marked as a decoy. + func isDecoyVideo(_ video: VideoDef) async -> Bool { + await secureImageRepository.isDecoyVideo(video) + } + + var navigationTitle: String { + if isSelectingDecoys { + return "Select Decoy Media" + } else { + return "Secure Gallery" + } + } + + var decoyCountText: String { + "\(selectedMediaIds.count) of \(maxDecoys) selected" + } + + var decoyCountTextColor: Color { + selectedMediaIds.count > maxDecoys ? .red : .secondary + } + + var isSaveDecoyButtonDisabled: Bool { + selectedMediaIds.isEmpty || isSavingDecoys + } + + var deleteAlertTitle: String { + "Delete \(selectedMediaIds.count > 1 ? "Items" : "Item")" + } + + var deleteAlertMessage: String { + "Are you sure you want to delete \(selectedMediaIds.count) item\(selectedMediaIds.count > 1 ? "s" : "")? This action cannot be undone." + } + + var decoyConfirmationMessage: String { + "Are you sure you want to save these \(selectedMediaIds.count) items as decoys? These will be shown when the emergency PIN is entered." + } + + var decoyLimitWarningMessage: String { + "You can select a maximum of \(maxDecoys) decoy items. Please deselect some before saving." + } + + // MARK: - Media Loading + + func loadMediaItems() { + Task { + // Load photos + let photoMetadata = await secureImageRepository.getPhotos() + let encKey = encryptionKey + let photos = photoMetadata.map { GalleryMediaItem(mediaItem: $0, encryptionKey: nil) } + + // Load videos + let videos = loadVideos(encryptionKey: encKey) + + // Combine and sort by date, oldest first. The grid fills + // upper-left to lower-right, so this puts the oldest item in the + // upper left and the newest in the lower right. + let allMedia = (photos + videos).sorted { item1, item2 in + let date1 = item1.dateTaken() ?? Date.distantPast + let date2 = item2.dateTaken() ?? Date.distantPast + return date1 < date2 + } + + mediaItems = allMedia + + if isSelectingDecoys { + var decoyIds = Set() + for item in allMedia where await isItemDecoy(item) { + decoyIds.insert(item.id) + } + // Re-check after the awaited enumeration: selection may have been + // cancelled while this loop was suspended. + if isSelectingDecoys { + selectedMediaIds.formUnion(decoyIds) + } + } + } + } + + private func loadVideos(encryptionKey: SymmetricKey?) -> [GalleryMediaItem] { + let videosDirectory = getVideosDirectory() + + guard FileManager.default.fileExists(atPath: videosDirectory.path) else { + return [] + } + + do { + let fileURLs = try FileManager.default.contentsOfDirectory( + at: videosDirectory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + + let videoFiles = fileURLs.filter { url in + let ext = url.pathExtension.lowercased() + return ext == "secv" + } + + return videoFiles.compactMap { videoURL in + let fileName = videoURL.deletingPathExtension().lastPathComponent + + return GalleryMediaItem( + mediaItem: VideoDef( + videoName: fileName, + videoFormat: videoURL.pathExtension, + videoFile: videoURL + ), + encryptionKey: encryptionKey + ) + } + + } catch { + Logger.storage.error("Failed to load videos", metadata: [ + "error": .string(error.localizedDescription) + ]) + return [] + } + } + + private func getVideosDirectory() -> URL { + let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return appSupportPath.appendingPathComponent("videos") + } + + // MARK: - Selection + + func toggleSelection(for mediaItem: GalleryMediaItem) { + if selectedMediaIds.contains(mediaItem.id) { + selectedMediaIds.remove(mediaItem.id) + } else { + if isSelectingDecoys && selectedMediaIds.count >= maxDecoys { + showDecoyLimitWarning = true + return + } + selectedMediaIds.insert(mediaItem.id) + } + } + + func isSelected(_ mediaItem: GalleryMediaItem) -> Bool { + selectedMediaIds.contains(mediaItem.id) + } + + func clearSelection() { + selectedMediaIds.removeAll() + } + + func startSelecting(mode: SelectionMode) { + selectionMode = mode + + if mode == .decoy { + selectedMediaIds.removeAll() + let items = mediaItems + Task { + var decoyIds = Set() + for item in items where await isItemDecoy(item) { + decoyIds.insert(item.id) + } + // Re-check after the awaited enumeration: the user may have + // cancelled decoy selection while this loop was suspended. + if isSelectingDecoys { + selectedMediaIds = decoyIds + } + } + } + } + + func cancelSelecting() { + selectionMode = .none + selectedMediaIds.removeAll() + } + + func exitDecoyMode() { + selectionMode = .none + selectedMediaIds.removeAll() + } + + // MARK: - Media Item Tap Handling + + func handleMediaTap(_ item: GalleryMediaItem) { + if isSelecting { + toggleSelection(for: item) + } else if item.mediaType == .video { + // Navigate to video player via selectedMediaItem + selectedMediaItem = item + } else { + // Navigate to photo detail via selectedMediaItem + selectedMediaItem = item + } + } + + // MARK: - Alert Triggers + + func showDeleteAlert() { + showDeleteConfirmation = true + } + + func showDecoyConfirmationAlert() { + if selectedMediaIds.count > maxDecoys { + showDecoyLimitWarning = true + } else { + showDecoyConfirmation = true + } + } + + // MARK: - Media Operations + + func deleteSelectedMedia() { + guard !selectedMediaIds.isEmpty else { return } + + let selectedItems = mediaItems.filter { selectedMediaIds.contains($0.id) } + + selectedMediaIds.removeAll() + selectionMode = .none + + Task { + for mediaItem in selectedItems { + if let photoDef = mediaItem.photoDef { + _ = await secureImageRepository.deleteImage(photoDef) + invalidatePhotoThumbnail(photoDef) + } else if let videoDef = mediaItem.videoDef { + try? FileManager.default.removeItem(at: videoDef.videoFile) + await secureImageRepository.deleteVideoThumbnail(forVideoNamed: videoDef.videoName) + _ = await secureImageRepository.removeDecoyVideo(videoDef) + invalidateVideoThumbnail(named: videoDef.videoName) + } + } + + withAnimation { + mediaItems.removeAll { item in + selectedItems.contains(where: { $0.id == item.id }) + } + } + } + } + + // MARK: - Import Operations + + func processPickerItems(_ newItems: [PhotosPickerItem]) { + guard !newItems.isEmpty else { return } + + isImporting = true + importProgress = 0 + + Task { + var hadSuccessfulImport = false + + for (index, item) in newItems.enumerated() { + importProgress = Float(index) / Float(newItems.count) + + if item.supportedContentTypes.contains(where: { $0.conforms(to: .movie) }) { + if let movie = try? await item.loadTransferable(type: ImportedMovie.self) { + let imported = await secureImageRepository.importVideo(from: movie.url) + try? FileManager.default.removeItem(at: movie.url) + if imported { hadSuccessfulImport = true } + } + } else if let data = try? await item.loadTransferable(type: Data.self) { + await processImportedImageData(data) + hadSuccessfulImport = true + } + } + + importProgress = 1.0 + try? await Task.sleep(nanoseconds: 300_000_000) + + pickerItems = [] + isImporting = false + + if hadSuccessfulImport { + loadMediaItems() + } + } + } + + private func processImportedImageData(_ imageData: Data) async { + guard let image = UIImage(data: imageData) else { return } + let capturedImage = CapturedImage( + sensorBitmap: image, timestamp: clock.now, rotationDegrees: 0 + ) + do { + _ = try await secureImageRepository.saveImage( + capturedImage, + location: nil, + applyRotation: true + ) + } catch { + Logger.storage.error("Error saving imported photo", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + + // MARK: - Decoy Operations + + func saveDecoySelections() async { + // Only items whose decoy state actually changes need work. + var pending: [GalleryMediaItem] = [] + for item in mediaItems where selectedMediaIds.contains(item.id) != (await isItemDecoy(item)) { + pending.append(item) + } + + guard !pending.isEmpty else { + selectionMode = .none + selectedMediaIds.removeAll() + return + } + + decoySaveTotal = pending.count + decoySaveCompleted = 0 + isSavingDecoys = true + // Give SwiftUI a beat to paint the overlay (and start the spinner + // animation) before the synchronous re-encryption work begins. + try? await Task.sleep(nanoseconds: 50_000_000) + + for item in pending { + let isSelected = selectedMediaIds.contains(item.id) + + if let photoDef = item.photoDef { + if isSelected { + if await addDecoyPhotoUseCase.addDecoyPhoto(photoDef: photoDef) == false { + Logger.ui.error("Failed to add decoy photo") + } + } else { + _ = await removeDecoyPhotoUseCase.removeDecoyPhoto(photoDef) + } + } else if let videoDef = item.videoDef { + if isSelected { + if await addDecoyVideoUseCase.addDecoyVideo(videoDef: videoDef) == false { + Logger.ui.error("Failed to add decoy video") + } + } else { + _ = await secureImageRepository.removeDecoyVideo(videoDef) + } + } + + decoySaveCompleted += 1 + await Task.yield() + } + + isSavingDecoys = false + selectionMode = .none + selectedMediaIds.removeAll() + } + + // MARK: - Sharing Operations + + func shareSelectedMedia() { + guard !selectedMediaIds.isEmpty else { return } + + Task { + await prepareAndShareMedia() + } + } + + private func prepareAndShareMedia() async { + let selectedItems = mediaItems.filter { selectedMediaIds.contains($0.id) } + var itemsToShare: [Any] = [] + + for mediaItem in selectedItems { + if let photoDef = mediaItem.photoDef { + if let data = try? await secureImageRepository.readImage(photoDef) { + if let fileURL = try? prepareForSharingUseCase.preparePhotoForSharing(imageData: data) { + itemsToShare.append(fileURL) + } + } + } else if let videoDef = mediaItem.videoDef, videoDef.isEncrypted, let encryptionKey = encryptionKey { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("temp_\(videoDef.videoName).mov") + + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + + do { + try await videoEncryptionService.decryptVideoForSharing( + inputURL: videoDef.videoFile, + outputURL: tempURL, + encryptionKey: encryptionKey + ) + itemsToShare.append(tempURL) + } catch { + Logger.media.error("Failed to decrypt video for sharing", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } else if let videoDef = mediaItem.videoDef { + itemsToShare.append(videoDef.videoFile) + } + } + + await MainActor.run { + presentShareSheet(with: itemsToShare) + } + } + + private func presentShareSheet(with items: [Any]) { + guard !items.isEmpty else { return } + + let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) + currentActivityController = activityViewController + + activityViewController.completionWithItemsHandler = { [weak self] _, completed, _, error in + if completed { + Logger.media.info("Media shared successfully") + } else if let error = error { + Logger.media.error("Media sharing failed", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + self?.currentActivityController = nil + self?.clearSelection() + } + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + var currentController = rootViewController + while let presented = currentController.presentedViewController { + currentController = presented + } + currentController.present(activityViewController, animated: true) + } + } + + // MARK: - Observers + + private func setupObservers() { + authorizationRepository.isAuthorized + .receive(on: DispatchQueue.main) + .sink { [weak self] isAuthorized in + if !isAuthorized { + self?.showDeleteConfirmation = false + self?.showDecoyLimitWarning = false + self?.showDecoyConfirmation = false + self?.currentActivityController?.dismiss(animated: false) + self?.currentActivityController = nil + self?.mediaItems.removeAll() + // Drop decrypted thumbnail bitmaps so they don't linger in + // memory after the session is locked or revoked. + self?.photoThumbnails.removeAll() + self?.videoThumbnails.removeAll() + } + } + .store(in: &cancellables) + } +} diff --git a/SnapSafe/Screens/Gallery/PhotoCell.swift b/SnapSafe/Screens/Gallery/PhotoCell.swift index ae1c358..bde8cdc 100644 --- a/SnapSafe/Screens/Gallery/PhotoCell.swift +++ b/SnapSafe/Screens/Gallery/PhotoCell.swift @@ -6,7 +6,6 @@ // import SwiftUI -import FactoryKit // Photo cell view for gallery items @@ -15,21 +14,19 @@ struct PhotoCell: View { let isSelected: Bool let isSelecting: Bool let onTap: () -> Void - let onDelete: () -> Void + @ObservedObject var galleryViewModel: MixedMediaGalleryViewModel - @Injected(\.secureImageRepository) - private var secureImageRepository: SecureImageRepository - // Track whether this cell is visible in the viewport @State private var isVisible: Bool = false - @State private var thumbnail: UIImage? = nil @State private var isDecoy: Bool = false - + // Cell size private let cellSize: CGFloat = 100 - + + private var thumbnail: UIImage? { galleryViewModel.photoThumbnails[photo.photoName] } + var body: some View { - + ZStack { // Photo image that fills the entire cell Image(uiImage: thumbnail ?? UIImage()) @@ -37,8 +34,7 @@ struct PhotoCell: View { .aspectRatio(contentMode: .fill) // Use .fill to cover the entire cell .frame(width: cellSize, height: cellSize) .clipped() // Clip any overflow - .cornerRadius(10) - .onTapGesture(perform: onTap) + .clipShape(.rect(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 3) @@ -53,37 +49,46 @@ struct PhotoCell: View { isVisible = false } - // Selection checkmark when in selection mode and selected (top-right) - if isSelecting && isSelected { + // Selection checkmark overlay (bottom-trailing) — kept identical to + // VideoCellView so photos and videos show the affordance in the same + // place, with an empty circle when unselected. + if isSelecting { VStack { + Spacer() HStack { Spacer() - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 24)) - .foregroundColor(.blue) - .background(Circle().fill(Color.white)) - .padding(5) + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .white) + .font(.title2) + .shadow(radius: 2) + .padding(6) } - Spacer() } } - + // Decoy indicator (bottom-left) if isDecoy { VStack { Spacer() HStack { Image(systemName: "shield.fill") - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.75)) + .font(.callout) + .foregroundStyle(.white.opacity(0.75)) .padding(5) Spacer() } } } - }.task { - thumbnail = await self.secureImageRepository.readThumbnail(photo) - isDecoy = secureImageRepository.isDecoyPhoto(photo) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Photo: \(photo.photoName)") + .accessibilityHint(isSelecting ? "Double-tap to \(isSelected ? "deselect" : "select")" : "Double-tap to open") + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) + .accessibilityActivationPoint(.center) + .onTapGesture(perform: onTap) + .task(id: photo.photoName) { + await galleryViewModel.loadThumbnail(for: photo) + isDecoy = await galleryViewModel.isDecoyPhoto(photo) } } } diff --git a/SnapSafe/Screens/Gallery/SecureGalleryView.swift b/SnapSafe/Screens/Gallery/SecureGalleryView.swift index 9c9b896..c480fbe 100644 --- a/SnapSafe/Screens/Gallery/SecureGalleryView.swift +++ b/SnapSafe/Screens/Gallery/SecureGalleryView.swift @@ -8,56 +8,64 @@ import PhotosUI import SwiftUI import Logging +import CryptoKit -// Empty state view when no photos exist +// Empty state view when no media exist struct EmptyGalleryView: View { - let onDismiss: () -> Void - var body: some View { VStack { Text("No photos yet") .font(.title) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) + .accessibilityLabel("Gallery is empty. Use the camera to take your first photo.") } } } -// Gallery view to display the stored photos +// Gallery view to display stored photos and videos struct SecureGalleryView: View { - @AppStorage("showFaceDetection") private var showFaceDetection = true // Using AppStorage to share with Settings - @StateObject private var viewModel: SecureGalleryViewModel + @StateObject private var viewModel: MixedMediaGalleryViewModel @Environment(\.dismiss) private var dismiss @EnvironmentObject private var nav: AppNavigationState - // Callback for dismissing the gallery let onDismiss: (() -> Void)? - // Initializers + // Standard initializer init(onDismiss: (() -> Void)? = nil) { self.onDismiss = onDismiss - self._viewModel = StateObject(wrappedValue: SecureGalleryViewModel()) + self._viewModel = StateObject(wrappedValue: MixedMediaGalleryViewModel()) } // Initializer for decoy selection mode init(selectingDecoys: Bool, onDismiss: (() -> Void)? = nil) { self.onDismiss = onDismiss - self._viewModel = StateObject(wrappedValue: SecureGalleryViewModel(selectingDecoys: selectingDecoys)) + self._viewModel = StateObject(wrappedValue: MixedMediaGalleryViewModel(selectingDecoys: selectingDecoys)) } var body: some View { ZStack { - Group { - if viewModel.photos.isEmpty { - EmptyGalleryView(onDismiss: { - onDismiss?() - dismiss() - }) - } else { - photosGridView + VStack(spacing: 0) { + // In decoy mode the title gets its own full-width row so it + // isn't truncated between the Back and Save buttons in the bar. + if viewModel.isSelectingDecoys { + Text(viewModel.navigationTitle) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color(.systemBackground)) + } + + Group { + if viewModel.mediaItems.isEmpty { + EmptyGalleryView() + } else { + mediaGridView + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } // Import progress overlay @@ -69,7 +77,7 @@ struct SecureGalleryView: View { Text("\(Int(viewModel.importProgress * 100))%") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .frame(width: 200) .padding() @@ -79,52 +87,77 @@ struct SecureGalleryView: View { .shadow(radius: 5) ) } + + // Decoy save / re-encryption overlay + if viewModel.isSavingDecoys { + Color.black.opacity(0.25) + .ignoresSafeArea() + + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + + Text("Saving decoy media…") + .font(.callout) + + if viewModel.decoySaveTotal > 1 { + Text("\(viewModel.decoySaveCompleted) of \(viewModel.decoySaveTotal)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemBackground)) + .shadow(radius: 5) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Saving decoy media") + } } - .navigationTitle(viewModel.navigationTitle) + .navigationTitle(viewModel.isSelectingDecoys ? "" : viewModel.navigationTitle) .navigationBarTitleDisplayMode(.inline) + // In decoy mode we supply our own leading Back button (which runs + // exitDecoyMode cleanup), so hide the system one to avoid two back buttons. + .navigationBarBackButtonHidden(viewModel.isSelectingDecoys) .toolbar { // Back button in the leading position (only for decoy selection mode) if viewModel.isSelectingDecoys { ToolbarItem(placement: .navigationBarLeading) { Button(action: { viewModel.exitDecoyMode() - onDismiss?() - dismiss() + if let onDismiss { onDismiss() } else { dismiss() } }) { HStack { Image(systemName: "chevron.left") Text("Back") } } + .disabled(viewModel.isSavingDecoys) } } - // Action buttons in the trailing position (simplified for top toolbar) + // Action buttons in the trailing position ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 16) { if viewModel.isSelectingDecoys { - // Count label and Save button for decoy selection - Text(viewModel.decoyCountText) - .font(.caption) - .foregroundColor(viewModel.decoyCountTextColor) - Button("Save") { viewModel.showDecoyConfirmationAlert() } - .foregroundColor(.blue) + .foregroundStyle(.blue) .disabled(viewModel.isSaveDecoyButtonDisabled) } else if viewModel.isSelecting { - // Cancel selection button Button("Cancel") { viewModel.cancelSelecting() } - .foregroundColor(.red) + .foregroundStyle(.red) } else { Menu { Button { viewModel.startSelecting(mode: .share) } label: { - Label("Select Photos", systemImage: "checkmark.circle") + Label("Select Items", systemImage: "checkmark.circle") } Button { @@ -146,13 +179,15 @@ struct SecureGalleryView: View { } } } - + // Bottom toolbar with main action buttons ToolbarItemGroup(placement: .bottomBar) { switch viewModel.selectionMode { case .none: - // Normal mode: Import button only - PhotosPicker(selection: $viewModel.pickerItems, matching: .images, photoLibrary: .shared()) { + // Parameterless form: same out-of-process picker, but the + // binary carries no PHPhotoLibrary reference for App Store + // upload scanning to flag. + PhotosPicker(selection: $viewModel.pickerItems, matching: .any(of: [.images, .videos])) { Label("Import", systemImage: "square.and.arrow.down") } .onChange(of: viewModel.pickerItems) { _, newItems in @@ -162,107 +197,218 @@ struct SecureGalleryView: View { Spacer() case .share: - // Share mode: Share button (only show when photos selected) if viewModel.hasSelection { Spacer() - Button(action: viewModel.shareSelectedPhotos) { + Button(action: viewModel.shareSelectedMedia) { Label("Share", systemImage: "square.and.arrow.up") } } case .delete: - // Delete mode: Delete button (only show when photos selected) if viewModel.hasSelection { Button(action: { - Logger.ui.info("Delete button pressed in gallery view, selected photos: \(viewModel.selectedPhotoIds.count)") + Logger.ui.info("Delete button pressed in gallery view, selected items: \(viewModel.selectedMediaIds.count)") viewModel.showDeleteAlert() }) { Label("Delete", systemImage: "trash") - .foregroundColor(.red) + .foregroundStyle(.red) } Spacer() } case .decoy: - // Decoy mode: no bottom toolbar actions - EmptyView() + // Decoy count lives here (centered in the bottom bar) rather + // than crammed beside the Save button in the nav bar. + Spacer() + Text(viewModel.decoyCountText) + .font(.callout.weight(.semibold)) + .foregroundStyle(viewModel.decoyCountTextColor) + .lineLimit(1) + .fixedSize() + Spacer() } } } .onAppear(perform: viewModel.onAppear) - .onChange(of: viewModel.selectedPhoto) { _, newValue in - if let photoDef = newValue { - // Find the index of the selected photo in the photos array - if let initialIndex = viewModel.photos.firstIndex(where: { $0.photoName == photoDef.photoName }) { - nav.navigate(to: .photoDetail(allPhotos: viewModel.photos, initialIndex: initialIndex)) - } - // Reset selectedPhoto so it can be selected again - viewModel.selectedPhoto = nil + .onChange(of: viewModel.selectedMediaItem) { _, newValue in + guard let item = newValue else { return } + viewModel.selectedMediaItem = nil + + // Navigate into the mixed-media detail pager. Both photos and videos + // are passed so the user can swipe between all items in the gallery. + if let initialIndex = viewModel.mediaItems.firstIndex(where: { $0.id == item.id }) { + nav.navigate(to: .photoDetail(allMedia: viewModel.mediaItems, initialIndex: initialIndex)) } } - .alert( - viewModel.deleteAlertTitle, - isPresented: $viewModel.showDeleteConfirmation, - actions: { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - Logger.ui.info("Delete confirmation button pressed, deleting \(viewModel.selectedPhotoIds.count) photos") - viewModel.deleteSelectedPhotos() - } - }, - message: { - Text(viewModel.deleteAlertMessage) - } - ) - .alert( - "Too Many Decoys", - isPresented: $viewModel.showDecoyLimitWarning, - actions: { - Button("OK", role: .cancel) {} - }, - message: { - Text(viewModel.decoyLimitWarningMessage) + .alert( + viewModel.deleteAlertTitle, + isPresented: $viewModel.showDeleteConfirmation, + actions: { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Logger.ui.info("Delete confirmation button pressed, deleting \(viewModel.selectedMediaIds.count) items") + viewModel.deleteSelectedMedia() } - ) - .alert( - "Save Decoy Selection", - isPresented: $viewModel.showDecoyConfirmation, - actions: { - Button("Cancel", role: .cancel) {} - Button("Save") { - viewModel.saveDecoySelections() - onDismiss?() - dismiss() + }, + message: { + Text(viewModel.deleteAlertMessage) + } + ) + .alert( + "Too Many Decoys", + isPresented: $viewModel.showDecoyLimitWarning, + actions: { + Button("OK", role: .cancel) {} + }, + message: { + Text(viewModel.decoyLimitWarningMessage) + } + ) + .alert( + "Save Decoy Selection", + isPresented: $viewModel.showDecoyConfirmation, + actions: { + Button("Cancel", role: .cancel) {} + Button("Save") { + Task { + await viewModel.saveDecoySelections() + if let onDismiss { onDismiss() } else { dismiss() } } - }, - message: { - Text(viewModel.decoyConfirmationMessage) } - ) - } + }, + message: { + Text(viewModel.decoyConfirmationMessage) + } + ) + .supportedOrientations(.allButUpsideDown) + } - // Photo grid subview - private var photosGridView: some View { + // Mixed media grid subview + private var mediaGridView: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 10) { - ForEach(viewModel.photos) { photo in - PhotoCell( - photo: photo, - isSelected: viewModel.selectedPhotoIds.contains(photo), - isSelecting: viewModel.isSelecting, - onTap: { - viewModel.handlePhotoTap(photo) - }, - onDelete: { - viewModel.prepareToDeleteSinglePhoto(photo) - } - ) + ForEach(viewModel.mediaItems) { item in + if let photoDef = item.photoDef { + PhotoCell( + photo: photoDef, + isSelected: viewModel.isSelected(item), + isSelecting: viewModel.isSelecting, + onTap: { viewModel.handleMediaTap(item) }, + galleryViewModel: viewModel + ) + } else if item.mediaType == .video { + VideoCellView( + item: item, + isSelected: viewModel.isSelected(item), + isSelecting: viewModel.isSelecting, + onTap: { viewModel.handleMediaTap(item) }, + galleryViewModel: viewModel + ) + } } } .padding() } } +} + +// MARK: - Video Cell View + +struct VideoCellView: View { + let item: GalleryMediaItem + let isSelected: Bool + let isSelecting: Bool + let onTap: () -> Void + @ObservedObject var galleryViewModel: MixedMediaGalleryViewModel + + @State private var isDecoy: Bool = false + + private var thumbnail: UIImage? { + guard let name = item.videoDef?.videoName else { return nil } + return galleryViewModel.videoThumbnails[name] + } + + private let cellSize: CGFloat = 100 + + var body: some View { + Button(action: onTap) { + ZStack { + // Thumbnail (or placeholder while loading / when unavailable) + ZStack { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Color(.systemGray5) + Image(systemName: "video.fill") + .font(.title) + .foregroundStyle(.secondary) + } + } + .frame(width: cellSize, height: cellSize) + .clipped() + .clipShape(.rect(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 3) + ) + + // Play badge (top-trailing) marks the item as a video + VStack { + HStack { + Spacer() + Image(systemName: "play.circle.fill") + .font(.title3) + .foregroundStyle(.white) + .shadow(radius: 2) + .padding(4) + } + Spacer() + } + // Decoy indicator (bottom-leading) + if isDecoy { + VStack { + Spacer() + HStack { + Image(systemName: "shield.fill") + .font(.callout) + .foregroundStyle(.white.opacity(0.75)) + .padding(5) + Spacer() + } + } + } + + // Selection checkmark overlay + if isSelecting { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .blue : .white) + .font(.title2) + .shadow(radius: 2) + .padding(6) + } + } + } + } + .frame(width: cellSize, height: cellSize) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("Video: \(item.mediaName)") + .accessibilityHint(isSelecting ? "Double-tap to \(isSelected ? "deselect" : "select")" : "Double-tap to open") + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + .task(id: item.videoDef?.videoName) { + if let videoDef = item.videoDef { + await galleryViewModel.loadVideoThumbnail(for: videoDef) + isDecoy = await galleryViewModel.isDecoyVideo(videoDef) + } + } + } } diff --git a/SnapSafe/Screens/Gallery/SecureGalleryViewModel.swift b/SnapSafe/Screens/Gallery/SecureGalleryViewModel.swift deleted file mode 100644 index c87d25b..0000000 --- a/SnapSafe/Screens/Gallery/SecureGalleryViewModel.swift +++ /dev/null @@ -1,623 +0,0 @@ -// -// SecureGalleryViewModel.swift -// SnapSafe -// -// Created by Claude on 9/6/25. -// - -import Foundation -import PhotosUI -import SwiftUI -import Combine -import FactoryKit -import Logging - -enum SelectionMode { - case none - case share - case delete - case decoy -} - -@MainActor -final class SecureGalleryViewModel: ObservableObject { - // MARK: - Published Properties - - @Published var photos: [PhotoDef] = [] - @Published var selectedPhoto: PhotoDef? - @Published var selectionMode: SelectionMode = .none - @Published var selectedPhotoIds = Set() - @Published var showDeleteConfirmation = false - @Published var isShowingImagePicker = false - @Published var importedImage: UIImage? - @Published var pickerItems: [PhotosPickerItem] = [] - @Published var isImporting: Bool = false - @Published var importProgress: Float = 0 - - // Legacy support for existing code - var isSelecting: Bool { selectionMode != .none } - var isSelectingDecoys: Bool { selectionMode == .decoy } - @Published var maxDecoys: Int = 10 - @Published var showDecoyLimitWarning: Bool = false - @Published var showDecoyConfirmation: Bool = false - @Published var isPoisonPillConfigured: Bool = false - - // MARK: - Dependencies - - @Injected(\.secureImageRepository) - private var secureImageRepository: SecureImageRepository - - @Injected(\.clock) - private var clock: Clock - - @Injected(\.addDecoyPhotoUseCase) - private var addDecoyPhotoUseCase: AddDecoyPhotoUseCase - - @Injected(\.removeDecoyPhotoUseCase) - private var removeDecoyPhotoUseCase: RemoveDecoyPhotoUseCase - - @Injected(\.prepareForSharingUseCase) - private var prepareForSharingUseCase: PrepareForSharingUseCase - - @Injected(\.authorizationRepository) - private var authorizationRepository: AuthorizationRepository - - @Injected(\.pinRepository) - private var pinRepository: PinRepository - - private var cancellables = Set() - - // Track currently presented activity controller for dismissal - private weak var currentActivityController: UIActivityViewController? - - // MARK: - Initialization - - init(selectingDecoys: Bool = false) { - self.selectionMode = selectingDecoys ? .decoy : .none - - setupObservers() - } - - // MARK: - Computed Properties - - var hasSelection: Bool { - !selectedPhotoIds.isEmpty - } - - var currentDecoyCount: Int { - photos.filter { secureImageRepository.isDecoyPhoto($0) }.count - } - - func selectedPhotos() async -> [UIImage] { - let selected = photos.filter { selectedPhotoIds.contains($0) } - var result: [UIImage] = [] - for photoDef in selected { - do { - let img = try await secureImageRepository.readImage(photoDef) - result.append(img) - } catch { - Logger.storage.error("Error loading image", metadata: [ - "photoName": .string(photoDef.photoName), - "error": .string(String(describing: error)) - ]) - } - } - return result - } - - var navigationTitle: String { - if isSelectingDecoys { - return "Select Decoy Photos" - } else { - return "Secure Gallery" - } - } - - var decoyCountText: String { - "\(selectedPhotoIds.count)/\(maxDecoys)" - } - - var decoyCountTextColor: Color { - selectedPhotoIds.count > maxDecoys ? .red : .secondary - } - - var isSaveDecoyButtonDisabled: Bool { - selectedPhotoIds.isEmpty - } - - var deleteAlertTitle: String { - "Delete Photo\(selectedPhotoIds.count > 1 ? "s" : "")" - } - - var deleteAlertMessage: String { - "Are you sure you want to delete \(selectedPhotoIds.count) photo\(selectedPhotoIds.count > 1 ? "s" : "")? This action cannot be undone." - } - - var decoyConfirmationMessage: String { - "Are you sure you want to save these \(selectedPhotoIds.count) photos as decoys? These will be shown when the emergency PIN is entered." - } - - var decoyLimitWarningMessage: String { - "You can select a maximum of \(maxDecoys) decoy photos. Please deselect some photos before saving." - } - - // MARK: - Public Methods - - func onAppear() { - loadPhotos() - loadPoisonPillConfiguration() - } - - func loadPoisonPillConfiguration() { - Task { - let hasPoisonPill = await pinRepository.hasPoisonPillPin() - await MainActor.run { - isPoisonPillConfigured = hasPoisonPill - } - } - } - - func onSelectedPhotoChange(_ newValue: PhotoDef?) { - if newValue == nil { - loadPhotos() - } - } - - func handlePhotoTap(_ photo: PhotoDef) { - if isSelecting { - togglePhotoSelection(photo) - } else { - selectedPhoto = photo - } - } - - func togglePhotoSelection(_ photo: PhotoDef) { - if selectedPhotoIds.contains(photo) { - selectedPhotoIds.remove(photo) - } else { - // If we're selecting decoys and already at the limit, don't allow more selections - if isSelectingDecoys && selectedPhotoIds.count >= maxDecoys { - showDecoyLimitWarning = true - return - } - selectedPhotoIds.insert(photo) - } - } - - func prepareToDeleteSinglePhoto(_ photo: PhotoDef) { - selectedPhotoIds = [photo] - showDeleteConfirmation = true - } - - func startSelecting(mode: SelectionMode) { - selectionMode = mode - - // If entering decoy mode, pre-select all existing decoy photos - if mode == .decoy { - selectedPhotoIds.removeAll() - for photoDef in photos { - if secureImageRepository.isDecoyPhoto(photoDef) { - selectedPhotoIds.insert(photoDef) - } - } - } - } - - func cancelSelecting() { - selectionMode = .none - selectedPhotoIds.removeAll() - } - - func exitDecoyMode() { - selectionMode = .none - selectedPhotoIds.removeAll() - } - - func showDecoyLimitAlert() { - showDecoyLimitWarning = true - } - - func showDecoyConfirmationAlert() { - if selectedPhotoIds.count > maxDecoys { - showDecoyLimitWarning = true - } else { - showDecoyConfirmation = true - } - } - - func showDeleteAlert() { - showDeleteConfirmation = true - } - - func processPickerItems(_ newItems: [PhotosPickerItem]) { - Task { - var hadSuccessfulImport = false - - // Show import progress to user - let importCount = newItems.count - if importCount > 0 { - // Update UI to show import is happening - isImporting = true - importProgress = 0 - - Logger.ui.info("Importing photos", metadata: [ - "count": .stringConvertible(importCount) - ]) - - // Process each selected item with progress tracking - for (index, item) in newItems.enumerated() { - // Update progress - let currentProgress = Float(index) / Float(importCount) - importProgress = currentProgress - - // Load and process the image - if let data = try? await item.loadTransferable(type: Data.self) { - // Process this image - await processImportedImageData(data) - hadSuccessfulImport = true - } - } - - // Show 100% progress briefly before hiding - importProgress = 1.0 - - // Small delay to show completion - try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds - } - - // After importing all items, reset the picker selection and refresh gallery - // Reset picked items - pickerItems = [] - - // Hide progress indicator - isImporting = false - - // Reload the gallery if we imported images - if hadSuccessfulImport { - loadPhotos() - } - } - } - - func deleteSelectedPhotos() { - Logger.ui.debug("deleteSelectedPhotos() called") - - // Create a local copy of the photos to delete - let photosToDelete = selectedPhotoIds.compactMap { photo in - photos.first(where: { $0 == photo }) - } - - Logger.ui.info("Will delete photos", metadata: [ - "count": .stringConvertible(photosToDelete.count), - "photoNames": .string(photosToDelete.map { $0.photoName }.joined(separator: ", ")) - ]) - - // Clear selection and exit selection mode immediately - // for better UI responsiveness - selectedPhotoIds.removeAll() - selectionMode = .none - - // Process deletions in a background queue - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - - Logger.ui.debug("Starting background deletion process") - - // Delete each photo - for photoDef in photosToDelete { - Logger.ui.debug("Attempting to delete photo", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - await self.secureImageRepository.deleteImage(photoDef) - Logger.ui.debug("Successfully deleted photo", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - } - - // After all deletions are complete, update the UI - await MainActor.run { - Logger.ui.debug("All deletions complete, updating UI") - - // Count photos before removal - let initialCount = self.photos.count - - // Remove deleted photos from our array - withAnimation { - self.photos.removeAll { photoDef in - let shouldRemove = photosToDelete.contains { $0.photoName == photoDef.photoName } - if shouldRemove { - Logger.ui.debug("Removing photo from UI", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - } - return shouldRemove - } - } - - // Verify removal - let finalCount = self.photos.count - let removedCount = initialCount - finalCount - Logger.ui.info("UI update complete", metadata: [ - "removedCount": .stringConvertible(removedCount), - "finalCount": .stringConvertible(finalCount) - ]) - } - } - } - - func saveDecoySelections() { - Task { - // First, un-mark any previously tagged decoys that aren't currently selected - for photoDef in photos { - let isCurrentlySelected = selectedPhotoIds.contains(photoDef) - let isCurrentlyDecoy = secureImageRepository.isDecoyPhoto(photoDef) - - // If it's currently a decoy but not selected, unmark it - if isCurrentlyDecoy && !isCurrentlySelected { - _ = removeDecoyPhotoUseCase.removeDecoyPhoto(photoDef) - } - // If it's selected but not a decoy, mark it - else if isCurrentlySelected && !isCurrentlyDecoy { - let success = await addDecoyPhotoUseCase.addDecoyPhoto(photoDef: photoDef) - if !success { - Logger.ui.error("Failed to add decoy photo \(photoDef)") - } else { - Logger.ui.info("Set photo as decoy \(photoDef)") - } - } - } - - // Reset selection and exit decoy mode - selectionMode = .none - selectedPhotoIds.removeAll() - } - } - - func shareSelectedPhotos() { - Task { - // Get all the selected photos - let images = await selectedPhotos() - guard !images.isEmpty else { return } - - // Find the root view controller - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootViewController = window.rootViewController - else { - Logger.ui.error("Could not find root view controller") - return - } - - // Find the presented view controller to present from - var currentController = rootViewController - while let presented = currentController.presentedViewController { - currentController = presented - } - - // Create and prepare temporary files with UUID filenames - var filesToShare: [URL] = [] - - for image in images { - if let imageData = image.jpegData(compressionQuality: 0.9) { - do { - let fileURL = try prepareForSharingUseCase.preparePhotoForSharing(imageData: imageData) - filesToShare.append(fileURL) - Logger.ui.debug("Prepared file for sharing", metadata: [ - "filename": .string(fileURL.lastPathComponent) - ]) - } catch { - Logger.ui.error("Error preparing photo for sharing", metadata: [ - "error": .string(error.localizedDescription) - ]) - } - } - } - - // Share files if any were successfully prepared - if !filesToShare.isEmpty { - // Create a UIActivityViewController to share the files - let activityViewController = UIActivityViewController( - activityItems: filesToShare, - applicationActivities: nil - ) - - // For iPad support - if let popover = activityViewController.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - // Store reference and present the share sheet - currentActivityController = activityViewController - currentController.present(activityViewController, animated: true) { - Logger.ui.info("Share sheet presented successfully", metadata: [ - "fileCount": .stringConvertible(filesToShare.count) - ]) - } - } else { - // Fallback to sharing just the images if file preparation failed for all - Logger.ui.debug("Falling back to sharing images directly") - - let activityViewController = UIActivityViewController( - activityItems: images, - applicationActivities: nil - ) - - // For iPad support - if let popover = activityViewController.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - // Store reference and present the share sheet - currentActivityController = activityViewController - currentController.present(activityViewController, animated: true, completion: nil) - } - } - } - - func clearMemoryForPhoto(_ photoDef: PhotoDef) { - self.secureImageRepository.thumbnailCache.evictThumbnail(photoDef) - } - - func clearMemoryForAllPhotos() { - // Clean up memory for all loaded images - self.secureImageRepository.thumbnailCache.clear() - } - - // MARK: - Private Methods - - private func dismissAllAlerts() { - // Dismiss all active alert states - showDeleteConfirmation = false - showDecoyLimitWarning = false - showDecoyConfirmation = false - - // Dismiss any currently presented activity controller (iOS export dialog) - currentActivityController?.dismiss(animated: false, completion: nil) - currentActivityController = nil - } - - private func setupObservers() { - // Monitor authorization state changes to dismiss alerts when unauthorized - authorizationRepository.isAuthorized - .receive(on: DispatchQueue.main) - .sink { [weak self] isAuthorized in - if !isAuthorized { - self?.dismissAllAlerts() - } - } - .store(in: &cancellables) - } - - private func loadPhotos() { - // Load photos in the background thread to avoid UI blocking - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - - // Load photo metadata - let photoMetadata = await self.secureImageRepository.getPhotos() - - // Sort photos by creation date (newest first, which is more typical for photo galleries) - let sortedPhotos = photoMetadata.sorted { photoDef1, photoDef2 in - let date1 = photoDef1.dateTaken() ?? Date.distantPast - let date2 = photoDef2.dateTaken() ?? Date.distantPast - return date1 > date2 // Newest first - } - - // Update UI on the main thread - await MainActor.run { - // First clear memory of existing photos if we're refreshing - self.secureImageRepository.thumbnailCache.clear() - - // Update the photos array - self.photos = sortedPhotos - - // If in decoy selection mode, pre-select existing decoy photos - if self.isSelectingDecoys { - // Find and select all photos that are already marked as decoys - for photoDef in sortedPhotos { - if self.secureImageRepository.isDecoyPhoto(photoDef) { - self.selectedPhotoIds.insert(photoDef) - } - } - - // Enable decoy selection mode - self.selectionMode = .decoy - } - } - } - } - - private func processImportedImageData(_ imageData: Data) async { - // Save the photo data (runs on background thread) - let filename = await withCheckedContinuation { continuation in - Task.detached { - do { - let image = UIImage(data: imageData)! - let capturedImage = await CapturedImage( - sensorBitmap: image, timestamp: self.clock.now, rotationDegrees: 0 - ) - // TODO: We should extract some info out of the existing meta data - let newDef = try await self.secureImageRepository.saveImage( - capturedImage, - location: nil, - applyRotation: true - ) - continuation.resume(returning: newDef.photoName) - } catch { - Logger.storage.error("Error saving imported photo", metadata: [ - "error": .string(error.localizedDescription) - ]) - continuation.resume(returning: "") - } - } - } - - if !filename.isEmpty { - Logger.storage.info("Successfully imported photo", metadata: [ - "filename": .string(filename) - ]) - } - } - - // Legacy method for backward compatibility - private func handleImportedImage() { - guard let image = importedImage else { return } - - // Convert image to data - guard let imageData = image.jpegData(compressionQuality: 0.8) else { - Logger.storage.error("Failed to convert image to data") - return - } - - // Process the image data using the new method - Task { - await processImportedImageData(imageData) - - // Reload photos to show the new one - await MainActor.run { - self.importedImage = nil - self.loadPhotos() - } - } - } - - private func deletePhoto(_ photoDef: PhotoDef) { - // Perform file deletion in background thread - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - - await self.secureImageRepository.deleteImage(photoDef) - - // Update UI on main thread - await MainActor.run { - // Remove from the local array - withAnimation { - self.photos.removeAll { $0 == photoDef } - if self.selectedPhotoIds.contains(photoDef) { - self.selectedPhotoIds.remove(photoDef) - } - } - } - } - } - - // Utility function to fix image orientation - private func fixImageOrientation(_ image: UIImage) -> UIImage { - // If the orientation is already correct, return the image as is - if image.imageOrientation == .up { - return image - } - - // Create a new CGContext with proper orientation - UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) - image.draw(in: CGRect(origin: .zero, size: image.size)) - let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - - return normalizedImage - } -} diff --git a/SnapSafe/Screens/PhotoDetail/Components/InlineVideoPlayerView.swift b/SnapSafe/Screens/PhotoDetail/Components/InlineVideoPlayerView.swift new file mode 100644 index 0000000..5af96c0 --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/Components/InlineVideoPlayerView.swift @@ -0,0 +1,245 @@ +// +// InlineVideoPlayerView.swift +// SnapSafe +// +// A full glass-native video page for the detail pager: a bare AVPlayerLayer +// surface with our own transport controls (play/pause, scrubber, time) and the +// glass action toolbar (Share/Decoy/Delete) stacked together at the bottom, so +// nothing overlaps. AVKit's built-in controls are not used. +// + +import SwiftUI +import AVKit + +struct InlineVideoPlayerView: View { + /// Called when the video is deleted, so the parent can pop the detail view. + let onRequestDismiss: () -> Void + /// Presents the video info sheet for this page's video. + let onInfo: () -> Void + /// Reports glass-control visibility so the page-level photo counter chip + /// can fade in/out alongside the video transport. + var onControlsVisibilityChange: ((Bool) -> Void)? = nil + /// Shared with the pager: true while the video is pinch-zoomed, which + /// disables paging and the dismiss drag (same contract as photo pages). + @Binding internal var isZoomed: Bool + + /// Pager-level chrome state; nil outside the pager (e.g. previews). + @Environment(PagerChromeState.self) private var chrome: PagerChromeState? + + private var isChromeSuppressed: Bool { chrome?.isDismissDragging ?? false } + + /// Owned by the hosting page controller, which drives playback setup and + /// teardown from UIKit appearance callbacks (see InlineVideoHostingController). + @ObservedObject var viewModel: VideoPlayerViewModel + @State private var scrubFraction: Double = 0 + @State private var showDeleteConfirmation = false + + init( + viewModel: VideoPlayerViewModel, + isZoomed: Binding = .constant(false), + onRequestDismiss: @escaping () -> Void, + onInfo: @escaping () -> Void = {}, + onControlsVisibilityChange: ((Bool) -> Void)? = nil + ) { + self._isZoomed = isZoomed + self.onRequestDismiss = onRequestDismiss + self.onInfo = onInfo + self.onControlsVisibilityChange = onControlsVisibilityChange + self.viewModel = viewModel + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 12) { + // Video area — fills the space above the action bar. The + // transport bar overlays its bottom edge (may overlap the + // last sliver of the frame, which is acceptable). + ZStack { + Group { + if let player = viewModel.player { + ZoomableScrollView( + minZoom: 1.0, + maxZoom: 6.0, + isZoomed: $isZoomed, + onSingleTap: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.toggleControls() + } + } + ) { + VideoSurfaceView(player: player) + } + } else if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + } else if viewModel.error != nil { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.white.opacity(0.7)) + Text("Could not play video") + .foregroundStyle(.white.opacity(0.7)) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.showControls && !isChromeSuppressed { + VStack { + Spacer() + VideoTransportBar( + isPlaying: viewModel.isPlaying, + didPlayToEnd: viewModel.didPlayToEnd, + isMuted: viewModel.isMuted, + currentTime: viewModel.currentTime, + duration: viewModel.duration, + fraction: $scrubFraction, + onPlayPause: { viewModel.togglePlayback() }, + onReplay: { viewModel.replay() }, + onToggleMute: { viewModel.toggleMute() }, + onScrubBegan: { viewModel.beginScrubbing() }, + onScrubEnded: { viewModel.endScrubbing(atFraction: scrubFraction) } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + .ignoresSafeArea(edges: .top) + + // Action bar — sits BELOW the video area, never overlapping it. + // Use opacity (not conditional rendering) for the dismiss-drag + // fade so the VStack doesn't reflow mid-drag; reflow would race + // the offset update and make the video frame shake. + if viewModel.showControls { + VideoDetailToolbar( + onInfo: onInfo, + onShare: { viewModel.share() }, + onDelete: { showDeleteConfirmation = true }, + onToggleDecoy: { viewModel.toggleDecoy() }, + showDecoyButton: viewModel.isPoisonPillConfigured, + decoyButtonTitle: viewModel.decoyButtonTitle, + decoyButtonIcon: viewModel.decoyButtonIcon, + isDecoyOperationLoading: viewModel.isDecoyOperationLoading + ) + .opacity(isChromeSuppressed ? 0 : 1) + .allowsHitTesting(!isChromeSuppressed) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + .animation(.easeInOut(duration: 0.2), value: isChromeSuppressed) + .onChange(of: scrubFraction) { _, fraction in + if viewModel.isScrubbing { viewModel.scrub(toFraction: fraction) } + } + .onChange(of: viewModel.currentTime) { _, _ in + guard !viewModel.isScrubbing, let duration = viewModel.duration, duration > 0 else { return } + scrubFraction = viewModel.currentTime / duration + } + .onAppear { + viewModel.loadActionState() + } + .onDisappear { + // Safety net for when the whole pager goes away (e.g. the detail + // view is popped). Swipes are handled by the hosting controller. + viewModel.cleanup() + } + .onChange(of: viewModel.showControls, initial: true) { _, visible in + onControlsVisibilityChange?(visible) + } + .alert("Delete Video", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + viewModel.deleteVideo() + onRequestDismiss() + } + } message: { + Text("Are you sure you want to delete this video? This action cannot be undone.") + } + .alert("Decoy Limit Reached", isPresented: $viewModel.showDecoyLimitAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("You can have a maximum of 10 decoy items. Remove an existing decoy before adding a new one.") + } + } +} + +// MARK: - Transport bar + +private struct VideoTransportBar: View { + let isPlaying: Bool + let didPlayToEnd: Bool + let isMuted: Bool + let currentTime: TimeInterval + let duration: TimeInterval? + @Binding var fraction: Double + let onPlayPause: () -> Void + let onReplay: () -> Void + let onToggleMute: () -> Void + let onScrubBegan: () -> Void + let onScrubEnded: () -> Void + + private var leadingIcon: String { + if didPlayToEnd { return "arrow.counterclockwise" } + return isPlaying ? "pause.fill" : "play.fill" + } + + var body: some View { + HStack(spacing: 12) { + Button(action: didPlayToEnd ? onReplay : onPlayPause) { + Image(systemName: leadingIcon) + .font(.title3) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(didPlayToEnd ? "Replay" : (isPlaying ? "Pause" : "Play")) + + Text(currentTime.formattedTime) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.primary) + + Slider(value: $fraction, in: 0...1) { editing in + if editing { onScrubBegan() } else { onScrubEnded() } + } + .tint(.primary) + + Text((duration ?? 0).formattedTime) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.primary.opacity(0.7)) + + Button(action: onToggleMute) { + Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.title3) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isMuted ? "Unmute" : "Mute") + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .glassTransportBackground() + .padding(.horizontal, 24) + .sensoryFeedback(.impact(weight: .light), trigger: isPlaying) + } +} + +private extension View { + @ViewBuilder + func glassTransportBackground() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular, in: .capsule) + .environment(\.colorScheme, .dark) + } else { + self.background(.ultraThinMaterial, in: .capsule) + .environment(\.colorScheme, .dark) + } + } +} diff --git a/SnapSafe/Screens/PhotoDetail/Components/MediaDetailToolbar.swift b/SnapSafe/Screens/PhotoDetail/Components/MediaDetailToolbar.swift new file mode 100644 index 0000000..051fcd4 --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/Components/MediaDetailToolbar.swift @@ -0,0 +1,180 @@ +// +// MediaDetailToolbar.swift +// SnapSafe +// +// Liquid Glass floating toolbars for the photo/video detail pager. +// Photo toolbar: Info · Obfuscate · Share · Decoy · Delete +// Video toolbar: Share · Decoy · Delete (Obfuscate doesn't apply to video) +// + +import SwiftUI + +// MARK: - Photo toolbar + +struct PhotoDetailToolbar: View { + var onInfo: () -> Void + var onObfuscate: () -> Void + var onShare: () -> Void + var onDelete: () -> Void + var onToggleDecoy: (() -> Void)? + var isZoomed: Bool + var showDecoyButton: Bool + var decoyButtonTitle: String + var decoyButtonIcon: String + var isDecoyOperationLoading: Bool + + var body: some View { + toolbar + .opacity(isZoomed ? 0 : 1) + .animation(.easeInOut(duration: 0.2), value: isZoomed) + } + + private var toolbar: some View { + HStack(spacing: 0) { + MediaToolbarButton(icon: "info.circle", label: "Info", action: onInfo) + MediaToolbarButton(icon: "face.dashed", label: "Obfuscate", action: onObfuscate) + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", action: onShare) + + if showDecoyButton { + if isDecoyOperationLoading { + MediaToolbarButton(icon: nil, label: decoyButtonTitle, action: {}) { + ProgressView() + .controlSize(.small) + } + .disabled(true) + } else { + MediaToolbarButton(icon: decoyButtonIcon, label: decoyButtonTitle, + action: { onToggleDecoy?() }) + } + } + + MediaToolbarButton(icon: "trash", label: "Delete", tint: .red, action: onDelete) + } + .glassToolbarBackground() + .padding(.horizontal, 24) + .padding(.bottom, 20) + } +} + +// MARK: - Video toolbar + +struct VideoDetailToolbar: View { + var onInfo: () -> Void + var onShare: () -> Void + var onDelete: () -> Void + var onToggleDecoy: (() -> Void)? + var showDecoyButton: Bool + var decoyButtonTitle: String + var decoyButtonIcon: String + var isDecoyOperationLoading: Bool + + var body: some View { + HStack(spacing: 0) { + MediaToolbarButton(icon: "info.circle", label: "Info", action: onInfo) + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", action: onShare) + + if showDecoyButton { + if isDecoyOperationLoading { + MediaToolbarButton(icon: nil, label: decoyButtonTitle, action: {}) { + ProgressView() + .controlSize(.small) + } + .disabled(true) + } else { + MediaToolbarButton(icon: decoyButtonIcon, label: decoyButtonTitle, + action: { onToggleDecoy?() }) + } + } + + MediaToolbarButton(icon: "trash", label: "Delete", tint: .red, action: onDelete) + } + .glassToolbarBackground() + .padding(.horizontal, 24) + .padding(.bottom, 20) + } +} + +// MARK: - Shared button component + +/// A single toolbar item: icon above label, minimum 44 × 44 tap target. +struct MediaToolbarButton: View { + let icon: String? + let label: String + var tint: Color = .primary + let action: () -> Void + var indicator: (() -> Indicator)? + + @State private var tapTrigger = 0 + + init(icon: String?, label: String, tint: Color = .primary, + action: @escaping () -> Void, + @ViewBuilder _ indicator: @escaping () -> Indicator) { + self.icon = icon; self.label = label; self.tint = tint + self.action = action; self.indicator = indicator + } + + var body: some View { + Button { + tapTrigger &+= 1 + action() + } label: { + VStack(spacing: 4) { + if let indicator { + indicator() + .frame(height: 24) + } else if let icon { + Image(systemName: icon) + .font(.title3) + .frame(height: 24) + } + Text(label) + .font(.caption) + // Keep every item one line tall so a longer label (e.g. + // "Remove Decoy" vs "Add Decoy") can't wrap and change the + // toolbar's height when toggled. + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .foregroundStyle(tint) + .frame(maxWidth: .infinity) + .frame(minHeight: 44) + .padding(.vertical, 8) + // Required: makes the full stretched/padded frame hittable. Without + // it only the icon/text glyphs accept taps and presses landing in the + // surrounding area fall through to the pager behind the toolbar. + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .sensoryFeedback(.impact(weight: .light), trigger: tapTrigger) + } +} + +extension MediaToolbarButton where Indicator == EmptyView { + init(icon: String?, label: String, tint: Color = .primary, + action: @escaping () -> Void) { + self.icon = icon; self.label = label; self.tint = tint + self.action = action; self.indicator = nil + } +} + +// MARK: - Glass background + +extension View { + /// Liquid Glass on iOS 26+; `.ultraThinMaterial` on earlier versions. + /// Always rendered in dark mode: the toolbar floats over an immersive + /// black/photo background, so controls must always be light regardless + /// of the system appearance setting. + @ViewBuilder + func glassToolbarBackground() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular, in: .capsule) + .environment(\.colorScheme, .dark) + } else { + self.padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: .capsule) + .environment(\.colorScheme, .dark) + } + } +} diff --git a/SnapSafe/Screens/PhotoDetail/Components/PhotoControlsView.swift b/SnapSafe/Screens/PhotoDetail/Components/PhotoControlsView.swift index 24f9fb2..cd8cf19 100644 --- a/SnapSafe/Screens/PhotoDetail/Components/PhotoControlsView.swift +++ b/SnapSafe/Screens/PhotoDetail/Components/PhotoControlsView.swift @@ -4,142 +4,3 @@ // // Created by Bill Booth on 5/20/25. // - -import SwiftUI - -struct PhotoControlsView: View { - var onInfo: () -> Void - var onObfuscate: () -> Void - var onShare: () -> Void - var onDelete: () -> Void - var onToggleDecoy: (() -> Void)? - var isZoomed: Bool - var showDecoyButton: Bool - var decoyButtonTitle: String - var decoyButtonIcon: String - var isDecoyOperationLoading: Bool - - var body: some View { - VStack(spacing: 0) { - // Separator line - Divider() - .background(Color.gray.opacity(0.3)) - - HStack { - // Delete button - Button(action: onDelete) { - VStack(spacing: 4) { - Image(systemName: "trash") - .font(.system(size: 22)) - .frame(height: 22) - Text("Delete") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - - // Info button - Button(action: onInfo) { - VStack(spacing: 4) { - Image(systemName: "info.circle") - .font(.system(size: 22)) - .frame(height: 22) - Text("Info") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - - // Obfuscate faces button - Button(action: onObfuscate) { - VStack(spacing: 4) { - Image(systemName: "face.dashed") - .font(.system(size: 22)) - .frame(height: 22) - Text("Obfuscate") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - - // Decoy button (conditional) - if showDecoyButton { - Button(action: { - onToggleDecoy?() - }) { - VStack(spacing: 4) { - if isDecoyOperationLoading { - ProgressView() - .scaleEffect(0.7) - .frame(height: 22) - } else { - Image(systemName: decoyButtonIcon) - .font(.system(size: 22)) - .frame(height: 22) - } - Text(decoyButtonTitle) - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isDecoyOperationLoading) - .opacity(isDecoyOperationLoading ? 0.6 : 1.0) - } - - // Share button - Button(action: onShare) { - VStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 22)) - .frame(height: 22) - Text("Share") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - - - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(UIColor.systemBackground)) - } - .opacity(isZoomed ? 0 : 1) // Hide controls when zoomed - .animation(.easeInOut(duration: 0.2), value: isZoomed) - } -} - -struct PhotoControlsView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.gray - PhotoControlsView( - onInfo: {}, - onObfuscate: {}, - onShare: {}, - onDelete: {}, - onToggleDecoy: {}, - isZoomed: false, - showDecoyButton: true, - decoyButtonTitle: "Add Decoy", - decoyButtonIcon: "shield", - isDecoyOperationLoading: false - ) - } - } -} diff --git a/SnapSafe/Screens/PhotoDetail/Components/VideoSurfaceView.swift b/SnapSafe/Screens/PhotoDetail/Components/VideoSurfaceView.swift new file mode 100644 index 0000000..df9dcbe --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/Components/VideoSurfaceView.swift @@ -0,0 +1,42 @@ +// +// VideoSurfaceView.swift +// SnapSafe +// +// A bare video rendering surface backed by AVPlayerLayer — no transport +// controls. We provide our own glass controls, so AVKit's built-in controls +// (which can't be repositioned) are not used. +// + +import SwiftUI +import UIKit +import AVKit + +struct VideoSurfaceView: UIViewRepresentable { + let player: AVPlayer + + func makeUIView(context: Context) -> PlayerLayerView { + let view = PlayerLayerView() + view.backgroundColor = .clear + view.playerLayer.player = player + view.playerLayer.videoGravity = .resizeAspect + return view + } + + func updateUIView(_ uiView: PlayerLayerView, context: Context) { + if uiView.playerLayer.player !== player { + uiView.playerLayer.player = player + } + } + + final class PlayerLayerView: UIView { + override static var layerClass: AnyClass { AVPlayerLayer.self } + var playerLayer: AVPlayerLayer { + // `layerClass` guarantees the backing layer is an AVPlayerLayer; this + // cast can only fail if that override is removed. + guard let playerLayer = layer as? AVPlayerLayer else { + fatalError("Backing layer must be an AVPlayerLayer; check layerClass") + } + return playerLayer + } + } +} diff --git a/SnapSafe/Screens/PhotoDetail/Components/ZoomLevelIndicator.swift b/SnapSafe/Screens/PhotoDetail/Components/ZoomLevelIndicator.swift index f7aaa92..728c993 100644 --- a/SnapSafe/Screens/PhotoDetail/Components/ZoomLevelIndicator.swift +++ b/SnapSafe/Screens/PhotoDetail/Components/ZoomLevelIndicator.swift @@ -18,8 +18,8 @@ struct ZoomLevelIndicator: View { .frame(width: 60, height: 25) Text(String(format: "%.1fx", scale)) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) + .font(.footnote.bold()) + .foregroundStyle(.white) } .opacity(isVisible && scale != 1.0 ? 1.0 : 0.0) .animation(.easeInOut(duration: 0.2), value: scale) @@ -27,11 +27,3 @@ struct ZoomLevelIndicator: View { } } -struct ZoomLevelIndicator_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.gray - ZoomLevelIndicator(scale: 10.0, isVisible: true) - } - } -} diff --git a/SnapSafe/Screens/PhotoDetail/Components/ZoomableImageView.swift b/SnapSafe/Screens/PhotoDetail/Components/ZoomableImageView.swift index ba27d8e..982b9bb 100644 --- a/SnapSafe/Screens/PhotoDetail/Components/ZoomableImageView.swift +++ b/SnapSafe/Screens/PhotoDetail/Components/ZoomableImageView.swift @@ -4,190 +4,3 @@ // // Created by Bill Booth on 5/20/25. // - -import SwiftUI -import Logging - -// Move the preference key outside the generic view -struct ImageSizePreferenceKey: PreferenceKey { - nonisolated(unsafe) static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() - } -} - -struct ZoomableImageView: View { - // MARK: – Inputs - let image: UIImage - let geometrySize: CGSize - let canGoToPrevious: Bool - let canGoToNext: Bool - let onNavigatePrevious: () -> Void - let onNavigateNext: () -> Void - let onDismiss: () -> Void - let imageRotation: Double - let isFaceDetectionActive: Bool - @ViewBuilder var overlay: () -> Overlay - - // MARK: – Zoom state (communicated to parent) - @Binding var isZoomed: Bool - - // MARK: – Zoom / pan state - @State private var scale: CGFloat = 1 - @State private var lastScale: CGFloat = 1 - @State private var panOffset = CGSize.zero // when zoomed - @State private var accumulatedPan = CGSize.zero // keeps panning between drags - @State private var imageSize: CGSize = .zero // actual rendered image size - - // MARK: – Temporary drag state (non-zoomed) - @State private var swipeOffset: CGFloat = 0 // horizontal swipe - @State private var verticalDrag: CGFloat = 0 // pull-down - - var body: some View { - GeometryReader { g in - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .rotationEffect(.degrees(imageRotation)) - .scaleEffect(scale) - .offset(x: accumulatedPan.width + panOffset.width + swipeOffset, - y: accumulatedPan.height + panOffset.height + verticalDrag) - .frame(width: g.size.width, height: g.size.height) - .clipped() - .overlay(overlay()) - .background( - GeometryReader { imageGeometry in - Color.clear.preference( - key: ImageSizePreferenceKey.self, - value: imageGeometry.size - ) - } - ) - .onPreferenceChange(ImageSizePreferenceKey.self) { size in - imageSize = size - } - .ignoresSafeArea() - - // ---------- Pinch to zoom ---------- - .gesture( - MagnificationGesture() - .onChanged { v in -// Logger.ui.debug("Pinch onChange: v=\(v), lastScale=\(lastScale), scale=\(scale)") - let delta = v / lastScale - lastScale = v - let newScale = min(max(scale * delta, 0.5), 6) - scale = newScale -// Logger.ui.debug(" -> newScale=\(newScale)") - - // DON'T update binding during gesture - wait for onEnded - } - .onEnded { _ in -// Logger.ui.debug("Pinch onEnded: scale=\(scale)") - lastScale = 1 - if scale < 1 { - withAnimation { - scale = 1 - isZoomed = false - } - } else { - // Update binding after gesture completes - isZoomed = scale > 1.0 - - // Reset pan when done zooming - accumulatedPan = .zero - panOffset = .zero - } - } - ) - - // ---------- Drag (pan, swipe, dismiss) ---------- - .simultaneousGesture( - scale > 1 ? - DragGesture(minimumDistance: 0) - .onChanged { value in - // Calculate max pan bounds - let scaledWidth = imageSize.width * scale - let scaledHeight = imageSize.height * scale - let maxPanX = max(0, (scaledWidth - geometrySize.width) / 2) - let maxPanY = max(0, (scaledHeight - geometrySize.height) / 2) - panOffset.width = max(-maxPanX - accumulatedPan.width, - min(maxPanX - accumulatedPan.width, value.translation.width)) - panOffset.height = max(-maxPanY - accumulatedPan.height, - min(maxPanY - accumulatedPan.height, value.translation.height)) - } - .onEnded { value in - accumulatedPan.width += panOffset.width - accumulatedPan.height += panOffset.height - panOffset = .zero - } - : nil - ) - .simultaneousGesture( - scale <= 1 ? - DragGesture() - .onChanged { value in - guard !isFaceDetectionActive else { return } - - let dx = value.translation.width - let dy = value.translation.height - - if abs(dx) > abs(dy) { // HORIZONTAL SWIPE - swipeOffset = dx // live follow - } else if dy > 0 { // VERTICAL PULL-DOWN - verticalDrag = dy * 0.7 // some resistance - } - } - .onEnded { value in - guard !isFaceDetectionActive else { resetNonZoom() ; return } - - let dx = value.translation.width - let dy = value.translation.height - - if abs(dx) > abs(dy) { // ------------ PAGE ------------ - let threshold = geometrySize.width / 4 - let quick = abs(value.velocity.width) > 500 - let quickTh = geometrySize.width / 8 - - if (dx > threshold || (quick && dx > quickTh)) && canGoToPrevious { - onNavigatePrevious() - } else if (dx < -threshold || (quick && dx < -quickTh)) && canGoToNext { - onNavigateNext() - } - } else if dy > 0 { // ----------- DISMISS ---------- - let threshold = geometrySize.height * 0.25 - let quick = value.velocity.height > 800 - if dy > threshold || quick { - onDismiss() - } - } - resetNonZoom() - } - : nil - ) - - // ---------- Double-tap to toggle zoom ---------- - .onTapGesture(count: 2) { - withAnimation(.spring()) { - if scale > 1 { - scale = 1 - isZoomed = false - accumulatedPan = .zero - panOffset = .zero - } else { - scale = 2.5 - isZoomed = true - accumulatedPan = .zero - panOffset = .zero - } - } - } - } - } - - private func resetNonZoom() { - withAnimation(.spring) { - swipeOffset = 0 - verticalDrag = 0 - } - } -} diff --git a/SnapSafe/Screens/PhotoDetail/DismissPanGestureHandler.swift b/SnapSafe/Screens/PhotoDetail/DismissPanGestureHandler.swift index 459b2d3..af18593 100644 --- a/SnapSafe/Screens/PhotoDetail/DismissPanGestureHandler.swift +++ b/SnapSafe/Screens/PhotoDetail/DismissPanGestureHandler.swift @@ -4,104 +4,3 @@ // // Created by Bill Booth on 10/7/25. // - -import UIKit -import SwiftUI - -class DismissPanGestureHandler: NSObject, UIGestureRecognizerDelegate { - private weak var targetView: UIView? - private let isZoomedCallback: () -> Bool - private let onDragChanged: (CGFloat, CGFloat) -> Void // translation, progress - private let onDragEnded: (CGFloat, CGFloat, @escaping () -> Void) -> Void // translation, velocity, dismiss callback - - private var panGesture: UIPanGestureRecognizer? - - init( - targetView: UIView, - isZoomedCallback: @escaping () -> Bool, - onDragChanged: @escaping (CGFloat, CGFloat) -> Void, - onDragEnded: @escaping (CGFloat, CGFloat, @escaping () -> Void) -> Void - ) { - self.targetView = targetView - self.isZoomedCallback = isZoomedCallback - self.onDragChanged = onDragChanged - self.onDragEnded = onDragEnded - super.init() - - setupGesture() - } - - private func setupGesture() { - guard let targetView = targetView else { return } - - let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - pan.delegate = self - targetView.addGestureRecognizer(pan) - panGesture = pan - } - - @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { - guard let view = gesture.view else { return } - - let translation = gesture.translation(in: view) - let velocity = gesture.velocity(in: view) - - switch gesture.state { - case .changed: - // Only respond to downward drags - guard translation.y > 0 else { return } - - let progress = min(translation.y / (view.bounds.height * 0.4), 1.0) - onDragChanged(translation.y, progress) - - case .ended, .cancelled: - onDragEnded(translation.y, velocity.y) { - // Dismiss callback handled by view model - } - - default: - break - } - } - - // MARK: - UIGestureRecognizerDelegate - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - // Don't allow dismiss when zoomed - guard !isZoomedCallback() else { return false } - - // Only begin if this is a downward drag - if let pan = gestureRecognizer as? UIPanGestureRecognizer, - let view = gestureRecognizer.view { - let translation = pan.translation(in: view) - let velocity = pan.velocity(in: view) - - guard abs(translation.y) > abs(translation.x) || - abs(velocity.y) > abs(velocity.x) else { - return false - } - - return translation.y > 0 || velocity.y > 0 - } - - return true - } - - /// Allow simultaneous recognition with UIScrollView pan gestures - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - return otherGestureRecognizer.view is UIScrollView - } - - /// This gesture should fail if it's a horizontal swipe (let paging handle it) - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - // If the other gesture is from a page scroll view, we should wait - // to see if it's a page transition - return false - } -} diff --git a/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailView.swift b/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailView.swift index d6b9a8a..2fdd557 100644 --- a/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailView.swift +++ b/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailView.swift @@ -10,15 +10,29 @@ import SwiftUI import Logging +/// Shared layout constants for the photo detail pager. +internal enum PhotoDetailLayout { + /// Fixed height reserved at the bottom of every photo page so the floating + /// action toolbar sits BELOW the image instead of over it. It's a constant + /// (not the measured toolbar height) on purpose: a constant keeps each + /// photo's available area identical regardless of whether the neighbouring + /// page is a video, so the image never shifts vertically while paging. + /// Sized to clear the toolbar (≈80pt) on every supported OS version. + static let bottomReserve: CGFloat = 88 +} + internal struct DismissTransformModifier: ViewModifier { internal let isZoomed: Bool internal let scale: CGFloat - internal let verticalOffset: CGFloat + internal let offset: CGSize internal func body(content: Content) -> some View { content .scaleEffect(isZoomed ? 1.0 : scale) - .offset(y: isZoomed ? 0 : verticalOffset) + .offset( + x: isZoomed ? 0 : offset.width, + y: isZoomed ? 0 : offset.height + ) } } @@ -26,13 +40,13 @@ internal extension View { func dismissTransform( isZoomed: Bool, scale: CGFloat, - verticalOffset: CGFloat + offset: CGSize ) -> some View { modifier( DismissTransformModifier( isZoomed: isZoomed, scale: scale, - verticalOffset: verticalOffset + offset: offset ) ) } @@ -47,11 +61,11 @@ internal struct PhotoCounterChip: View { Spacer() Text(text) .font(.subheadline) - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.black.opacity(0.6)) - .cornerRadius(12) + .clipShape(.rect(cornerRadius: 12)) .opacity(opacity) Spacer() } @@ -61,18 +75,19 @@ internal struct PhotoCounterChip: View { struct EnhancedPhotoDetailView: View { @StateObject private var viewModel: EnhancedPhotoDetailViewModel + @State private var chromeState = PagerChromeState() @Environment(\.dismiss) private var dismiss @EnvironmentObject private var nav: AppNavigationState init( - allPhotos: [PhotoDef], + allMedia: [GalleryMediaItem], initialIndex: Int, onDelete: ((PhotoDef) -> Void)? = nil, onDismiss: (() -> Void)? = nil ) { _viewModel = StateObject( wrappedValue: EnhancedPhotoDetailViewModel( - allPhotos: allPhotos, + allMedia: allMedia, initialIndex: initialIndex, onDelete: onDelete, onDismiss: onDismiss @@ -90,9 +105,18 @@ struct EnhancedPhotoDetailView: View { // UIKit-based paging with proper gesture coordination PhotoPageViewController( - photos: viewModel.photoFiles, + allMedia: viewModel.allMedia, currentIndex: $viewModel.currentIndex, - isZoomed: $viewModel.isZoomed + isZoomed: $viewModel.isZoomed, + chromeState: chromeState, + isDismissDragging: viewModel.isDismissDragging, + onRequestDismiss: { dismiss() }, + onVideoInfo: { videoDef in nav.presentSheet(.videoInfo(videoDef)) }, + onVideoControlsVisibilityChange: { visible in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.isVideoControlsVisible = visible + } + } ) .onChange(of: viewModel.currentIndex) { _, newIndex in viewModel.handleIndexChange(newIndex: newIndex) @@ -101,14 +125,24 @@ struct EnhancedPhotoDetailView: View { .dismissTransform( isZoomed: viewModel.isZoomed, scale: viewModel.photoScaleEffect, - verticalOffset: viewModel.dragOffset.height + offset: viewModel.dragOffset + ) + // A tap on the media brings the counter chip back (and restarts + // its auto-hide). Simultaneous so it never blocks the scroll + // view's double-tap zoom, horizontal paging, or dismiss drag. + .simultaneousGesture( + TapGesture().onEnded { viewModel.showCounterThenAutoHide() } ) - // Bottom toolbar + // Floating toolbar — photos only. Video pages render their own + // glass controls (transport + actions) inside InlineVideoPlayerView. + // Each photo page reserves PhotoDetailLayout.bottomReserve at its + // bottom (constant, so the image never shifts when paging to/from + // a video), and this toolbar sits in that reserved band. VStack { Spacer() - if viewModel.currentIndex < viewModel.photoFiles.count { - PhotoControlsView( + if !viewModel.currentIsVideo, viewModel.currentIndex < viewModel.allMedia.count { + PhotoDetailToolbar( onInfo: { if let current = viewModel.currentPhotoDef { nav.presentSheet(.photoInfo(current)) @@ -128,9 +162,11 @@ struct EnhancedPhotoDetailView: View { decoyButtonIcon: viewModel.decoyButtonIcon, isDecoyOperationLoading: viewModel.isDecoyOperationLoading ) - .padding(.bottom, 8) } } + .opacity(viewModel.isDismissDragging ? 0 : 1) + .allowsHitTesting(!viewModel.isDismissDragging) + .animation(.easeInOut(duration: 0.2), value: viewModel.isDismissDragging) // Counter overlay VStack { @@ -147,18 +183,22 @@ struct EnhancedPhotoDetailView: View { .onChanged { value in guard viewModel.mayDismissByDrag() else { return } viewModel.handleDragChanged( - value, + translation: value.translation, geometryHeight: geometry.size.height ) } .onEnded { value in guard viewModel.mayDismissByDrag() else { return } viewModel.handleDragEnded( - value, + translation: value.translation, + verticalVelocity: value.velocity.height, geometryHeight: geometry.size.height ) { dismiss() } } ) + .onChange(of: viewModel.isDismissDragging) { _, dragging in + chromeState.isDismissDragging = dragging + } } .navigationBarHidden(true) .supportedOrientations(.allButUpsideDown) @@ -177,5 +217,15 @@ struct EnhancedPhotoDetailView: View { Text("Are you sure you want to delete this photo? This action cannot be undone.") } ) + .alert( + "Decoy Limit Reached", + isPresented: $viewModel.showDecoyLimitAlert, + actions: { + Button("OK", role: .cancel) {} + }, + message: { + Text("You can have a maximum of 10 decoy items. Remove an existing decoy before adding a new one.") + } + ) } } diff --git a/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailViewModel.swift b/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailViewModel.swift index 52897ae..ec4f803 100644 --- a/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailViewModel.swift +++ b/SnapSafe/Screens/PhotoDetail/EnhancedPhotoDetailViewModel.swift @@ -27,81 +27,125 @@ class EnhancedPhotoDetailViewModel: ObservableObject { @Injected(\.pinRepository) private var pinRepository: PinRepository - + // MARK: - Published Properties - - @Published var photoFiles: [PhotoDef] = [] + + @Published var allMedia: [GalleryMediaItem] = [] @Published var currentIndex: Int = 0 @Published var dragOffset: CGSize = .zero @Published var dismissProgress: CGFloat = 0 @Published var isTabViewTransitioning: Bool = false @Published var lastIndexChangeTime: Date = Date() + /// Tracks whether the inline video player on the current page is showing + /// its glass controls. Photos always treat this as visible. + @Published var isVideoControlsVisible: Bool = true + + /// Whether the "X of Y" counter chip is currently shown. It auto-hides a + /// few seconds after appearing / after each page change so it stops + /// covering the image. + @Published var isCounterVisible: Bool = true + private var counterHideTask: Task? + + /// How long the counter stays visible before fading out. + private let counterVisibleDuration: Duration = .seconds(5) // Toolbar state - @Published var showImageInfo = false @Published var showDeleteConfirmation = false + @Published var showDecoyLimitAlert = false @Published var isDecoyOperationLoading = false @Published var isPoisonPillConfigured = false - // Track currently presented activity controller for dismissal - private weak var currentActivityController: UIActivityViewController? - // MARK: - Configuration - + var onDelete: ((PhotoDef) -> Void)? var onDismiss: (() -> Void)? - + // MARK: - Initialization - - init(allPhotos: [PhotoDef], initialIndex: Int, onDelete: ((PhotoDef) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { - self.photoFiles = allPhotos + + init(allMedia: [GalleryMediaItem], initialIndex: Int, onDelete: ((PhotoDef) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { + self.allMedia = allMedia self.currentIndex = initialIndex self.onDelete = onDelete self.onDismiss = onDismiss } - + @Published internal var isZoomed: Bool = false + /// Per-gesture intent for the dismiss drag. Latched on the FIRST movement + /// of each gesture and never re-evaluated mid-gesture: a per-update + /// direction check made the image freeze ("catch") whenever the finger's + /// cumulative translation turned more horizontal than vertical. + enum DismissDragMode { + /// No gesture in flight (or gesture ended). + case undecided + /// First movement was vertical → this gesture dismisses; the offset + /// follows the finger on both axes until it ends. + case dismissing + /// First movement was horizontal → this gesture belongs to the pager; + /// ignore it entirely until it ends. + case rejected + } + + @Published private(set) var dragMode: DismissDragMode = .undecided + + /// True while a dismiss drag is engaged; drives chrome fade-out and + /// disables the pager's horizontal scroll. + var isDismissDragging: Bool { dragMode == .dismissing } + // Policy helpers (clear/consistent call sites + unit-testable) @inlinable internal func mayDismissByDrag() -> Bool { !isZoomed } - @inlinable internal func mayPageHorizontally() -> Bool { !isZoomed } - + // MARK: - Computed Properties - - var photoCount: Int { - photoFiles.count - } - + + var mediaCount: Int { allMedia.count } + var currentPhotoDisplayText: String { - "\(currentIndex + 1) of \(photoCount)" + "\(currentIndex + 1) of \(mediaCount)" } - + var backgroundOpacity: Double { 1.0 - dismissProgress * 0.8 } - + var photoScaleEffect: Double { 1.0 - dismissProgress * 0.2 } - + var overlayOpacity: Double { - // Fade out when zoomed or when dismissing - if isZoomed { - return 0.0 - } + if isZoomed { return 0.0 } + if isDismissDragging { return 0.0 } + if !isCounterVisible { return 0.0 } + if currentIsVideo && !isVideoControlsVisible { return 0.0 } return 1.0 - dismissProgress } - // Current photo computed properties + /// The current item regardless of type. + var currentMediaItem: GalleryMediaItem? { + guard currentIndex < allMedia.count else { return nil } + return allMedia[currentIndex] + } + + /// Non-nil only when the current page is a photo. var currentPhotoDef: PhotoDef? { - guard currentIndex < photoFiles.count else { return nil } - return photoFiles[currentIndex] + currentMediaItem?.photoDef } + /// True when the current page is a video. + var currentIsVideo: Bool { + currentMediaItem?.mediaType == .video + } - var isCurrentPhotoDecoy: Bool { - guard let photoDef = currentPhotoDef else { return false } - return secureImageRepository.isDecoyPhoto(photoDef) + @Published private(set) var isCurrentPhotoDecoy: Bool = false + + private func refreshDecoyState() { + guard let photoDef = currentPhotoDef else { + isCurrentPhotoDecoy = false + return + } + Task { + let decoy = await secureImageRepository.isDecoyPhoto(photoDef) + await MainActor.run { self.isCurrentPhotoDecoy = decoy } + } } var decoyButtonTitle: String { @@ -111,29 +155,30 @@ class EnhancedPhotoDetailViewModel: ObservableObject { var decoyButtonIcon: String { isCurrentPhotoDecoy ? "shield.slash" : "shield" } - + // MARK: - Index Management - + func handleIndexChange(newIndex: Int) { Logger.ui.debug("EnhancedPhotoDetailViewModel: currentIndex changed", metadata: [ "from": .stringConvertible(currentIndex), "to": .stringConvertible(newIndex) ]) - - // Track when TabView transitions occur + isTabViewTransitioning = true lastIndexChangeTime = Date() - - // Reset any dismiss progress during navigation + withAnimation(.easeOut(duration: 0.2)) { dragOffset = .zero dismissProgress = 0 } - - // Preload adjacent photos when index changes + dragMode = .undecided + + // Re-show the counter for the newly visible item, then fade it again. + showCounterThenAutoHide() + preloadAdjacentPhotos(currentIndex: newIndex) - - // Clear transition state after a delay + refreshDecoyState() + Task { try await Task.sleep(for: .milliseconds(800)) await MainActor.run { @@ -141,53 +186,55 @@ class EnhancedPhotoDetailViewModel: ObservableObject { } } } - + // MARK: - Preloading - + func preloadAdjacentPhotos(currentIndex: Int) { - guard !photoFiles.isEmpty else { return } - - // Preload previous photo thumbnail - if currentIndex > 0 { - let previousPhotoDef = photoFiles[currentIndex - 1] + // Preload only photo thumbnails (video thumbnails are loaded by their cells) + if currentIndex > 0, let prev = allMedia[currentIndex - 1].photoDef { Task(priority: .userInitiated) { - _ = await secureImageRepository.readThumbnail(previousPhotoDef) + _ = await secureImageRepository.readThumbnail(prev) } } - - // Preload next photo thumbnail - if currentIndex < photoFiles.count - 1 { - let nextPhotoDef = photoFiles[currentIndex + 1] + if currentIndex < allMedia.count - 1, let next = allMedia[currentIndex + 1].photoDef { Task(priority: .userInitiated) { - _ = await secureImageRepository.readThumbnail(nextPhotoDef) + _ = await secureImageRepository.readThumbnail(next) } } } - + // MARK: - Gesture Handling - - func handleDragChanged(_ value: DragGesture.Value, geometryHeight: CGFloat) { - // Bail out until the drag is clearly vertical - guard abs(value.translation.height) > abs(value.translation.width) else { return } - - dragOffset = CGSize(width: 0, height: value.translation.height) - dismissProgress = min(value.translation.height / (geometryHeight * 0.4), 1.0) + + func handleDragChanged(translation: CGSize, geometryHeight: CGFloat) { + if dragMode == .undecided { + dragMode = abs(translation.height) > abs(translation.width) + ? .dismissing + : .rejected + } + guard dragMode == .dismissing else { return } + + dragOffset = translation + dismissProgress = min(max(translation.height / (geometryHeight * 0.4), 0), 1) } - - func handleDragEnded(_ value: DragGesture.Value, geometryHeight: CGFloat, dismiss: @escaping () -> Void) { - // Same dominant-axis guard here *before* any threshold checks - guard abs(value.translation.height) > abs(value.translation.width) else { return } - + + func handleDragEnded( + translation: CGSize, + verticalVelocity: CGFloat, + geometryHeight: CGFloat, + dismiss: @escaping () -> Void + ) { + let wasDismissing = dragMode == .dismissing + dragMode = .undecided + guard wasDismissing else { return } + let dismissThreshold = geometryHeight * 0.25 - let isQuickDownSwipe = value.velocity.height > 2000 - - if value.translation.height > dismissThreshold || isQuickDownSwipe { - // Dismiss the view + let isQuickDownSwipe = verticalVelocity > 2000 + + if translation.height > dismissThreshold || isQuickDownSwipe { withAnimation(.easeOut(duration: 0.3)) { dragOffset = CGSize(width: 0, height: geometryHeight) dismissProgress = 1 } - Task { try await Task.sleep(for: .milliseconds(100)) await MainActor.run { @@ -196,19 +243,46 @@ class EnhancedPhotoDetailViewModel: ObservableObject { } } } else { - // Return to original position withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { dragOffset = .zero dismissProgress = 0 } } } - + // MARK: - View Lifecycle - + func onAppear() { preloadAdjacentPhotos(currentIndex: currentIndex) loadPoisonPillConfiguration() + showCounterThenAutoHide() + // Load the decoy state for the initially shown photo. handleIndexChange + // only fires when the index *changes* (a swipe), so without this the + // first photo's button label is stuck at its default ("Add Decoy"). + refreshDecoyState() + } + + /// Shows the counter chip and (for photos) schedules it to fade out after + /// `counterVisibleDuration`. Cancels any previously scheduled hide so the + /// timer restarts cleanly on each page change or tap. + /// + /// On video pages we don't run the timer: the counter there follows the + /// inline player's own control visibility (`isVideoControlsVisible`), which + /// already auto-hides. + func showCounterThenAutoHide() { + counterHideTask?.cancel() + if !isCounterVisible { + withAnimation(.easeInOut(duration: 0.25)) { isCounterVisible = true } + } + + guard !currentIsVideo else { return } + + counterHideTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: self.counterVisibleDuration) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.5)) { self.isCounterVisible = false } + } } func loadPoisonPillConfiguration() { @@ -219,27 +293,22 @@ class EnhancedPhotoDetailViewModel: ObservableObject { } } } - + // MARK: - Photo Management - - func deletePhoto(at index: Int) { - guard index < photoFiles.count else { return } - let photoDefToDelete = photoFiles[index] + func deletePhoto(at index: Int) { + guard index < allMedia.count, + let photoDef = allMedia[index].photoDef else { return } - // Perform file deletion in a background thread Task(priority: .userInitiated) { - // Actually delete the file Logger.ui.debug("Attempting to delete file", metadata: [ - "filename": .string(photoDefToDelete.photoName) + "filename": .string(photoDef.photoName) ]) - secureImageRepository.deleteImage(photoDefToDelete) + _ = await secureImageRepository.deleteImage(photoDef) Logger.ui.debug("File deletion successful") - - // All UI updates must happen on the main thread await MainActor.run { Logger.ui.debug("Calling onDelete callback") - onDelete?(photoDefToDelete) + onDelete?(photoDef) } } } @@ -251,26 +320,19 @@ class EnhancedPhotoDetailViewModel: ObservableObject { Task { do { - // First load the image - let image = try await secureImageRepository.readImage(photoDef) + let data = try await secureImageRepository.readImage(photoDef) + guard let image = UIImage(data: data) else { throw ImageRepositoryError.invalidImageData } - // Convert image to data for sharing with UUID filename if let imageData = image.jpegData(compressionQuality: 0.9) { - // Prepare photo for sharing with UUID filename let fileURL = try prepareForSharingUseCase.preparePhotoForSharing(imageData: imageData) Logger.ui.debug("Photo prepared for sharing successfully") - // Create activity controller with the temporary image let activityController = UIActivityViewController( activityItems: [fileURL], applicationActivities: nil ) - // Store reference for potential dismissal - currentActivityController = activityController - - // Present the activity controller if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { @@ -280,7 +342,6 @@ class EnhancedPhotoDetailViewModel: ObservableObject { } await MainActor.run { - // Configure popover presentation for iPad if let popoverController = activityController.popoverPresentationController { popoverController.sourceView = presentingViewController.view popoverController.sourceRect = CGRect( @@ -291,7 +352,6 @@ class EnhancedPhotoDetailViewModel: ObservableObject { ) popoverController.permittedArrowDirections = [] } - presentingViewController.present(activityController, animated: true) } } @@ -308,20 +368,32 @@ class EnhancedPhotoDetailViewModel: ObservableObject { isDecoyOperationLoading = true Task { - if isCurrentPhotoDecoy { + let decoy = await secureImageRepository.isDecoyPhoto(photoDef) + if decoy { Logger.ui.debug("Removing decoy status from photo", metadata: ["photoId": .stringConvertible(photoDef.id)]) + let removed = await removeDecoyPhotoUseCase.removeDecoyPhoto(photoDef) + Logger.ui.debug("removeDecoyPhoto result: \(removed)") await MainActor.run { - _ = removeDecoyPhotoUseCase.removeDecoyPhoto(photoDef) + isCurrentPhotoDecoy = false isDecoyOperationLoading = false } } else { + // Pre-check the limit so we can show the limit alert without + // attempting (and without conflating "at limit" with a crypto + // failure). The use case enforces the same limit authoritatively. + guard await secureImageRepository.numDecoys() < SecureImageRepository.maxDecoyItems else { + await MainActor.run { + isDecoyOperationLoading = false + showDecoyLimitAlert = true + } + return + } Logger.ui.debug("Adding decoy status to photo", metadata: ["photoId": .stringConvertible(photoDef.id)]) - // Add decoy status let success = await addDecoyPhotoUseCase.addDecoyPhoto(photoDef: photoDef) await MainActor.run { + isCurrentPhotoDecoy = success isDecoyOperationLoading = false } - if success { Logger.ui.info("Successfully added decoy status") } else { diff --git a/SnapSafe/Screens/PhotoDetail/ImageInfoView.swift b/SnapSafe/Screens/PhotoDetail/ImageInfoView.swift index 7550455..31326e2 100644 --- a/SnapSafe/Screens/PhotoDetail/ImageInfoView.swift +++ b/SnapSafe/Screens/PhotoDetail/ImageInfoView.swift @@ -19,8 +19,7 @@ struct ImageInfoView: View { } var body: some View { - NavigationView { - if viewModel.isLoading { + if viewModel.isLoading { ProgressView("Loading image information...") .navigationTitle("Image Information") .navigationBarTitleDisplayMode(.inline) @@ -38,21 +37,21 @@ struct ImageInfoView: View { Text("Filename") Spacer() Text(viewModel.filename) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack { Text("Resolution") Spacer() Text(viewModel.resolution) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack { Text("File Size") Spacer() Text(viewModel.fileSize) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -61,7 +60,7 @@ struct ImageInfoView: View { Text("Date Taken") Spacer() Text(viewModel.dateTaken) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if viewModel.originalDateString != "Not available" { @@ -69,7 +68,7 @@ struct ImageInfoView: View { Text("Original Date") Spacer() Text(viewModel.originalDateString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -79,13 +78,13 @@ struct ImageInfoView: View { Text("Orientation") Spacer() Text(viewModel.orientationString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } Section(header: Text("Location")) { Text(viewModel.locationString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Section(header: Text("Camera Information")) { @@ -97,7 +96,7 @@ struct ImageInfoView: View { Text("Camera") Spacer() Text(cameraInfo.cameraName) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -106,7 +105,7 @@ struct ImageInfoView: View { Text("Aperture") Spacer() Text(cameraInfo.apertureString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -115,7 +114,7 @@ struct ImageInfoView: View { Text("Shutter Speed") Spacer() Text(cameraInfo.shutterSpeedString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -124,7 +123,7 @@ struct ImageInfoView: View { Text("ISO") Spacer() Text(cameraInfo.isoString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -133,12 +132,12 @@ struct ImageInfoView: View { Text("Focal Length") Spacer() Text(cameraInfo.focalLengthString) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } else { Text("No camera information available") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -150,7 +149,7 @@ struct ImageInfoView: View { VStack(alignment: .leading) { Text(key) .font(.headline) - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("\(String(describing: viewModel.rawMetadata[key]!))") .font(.caption) } @@ -169,7 +168,6 @@ struct ImageInfoView: View { } } } - } } } } diff --git a/SnapSafe/Screens/PhotoDetail/ImageInfoViewModel.swift b/SnapSafe/Screens/PhotoDetail/ImageInfoViewModel.swift index 7cebf3b..29f6c4a 100644 --- a/SnapSafe/Screens/PhotoDetail/ImageInfoViewModel.swift +++ b/SnapSafe/Screens/PhotoDetail/ImageInfoViewModel.swift @@ -145,12 +145,13 @@ class ImageInfoViewModel: ObservableObject { let metadata = try await secureImageRepository.getPhotoMetaData(photoDef) // Load the full image - let image = try await secureImageRepository.readImage(photoDef) - + let imageData = try await secureImageRepository.readImage(photoDef) + guard let image = UIImage(data: imageData) else { throw ImageRepositoryError.invalidImageData } + // Load raw JPEG data to extract raw metadata - let jpegData = try await secureImageRepository.decryptJpg(photoDef: photoDef) + let jpegData = try await secureImageRepository.decryptJpg(photoDef) let rawMeta = extractRawMetadata(from: jpegData) - + await MainActor.run { self.imageMetadata = metadata self.fullImage = image diff --git a/SnapSafe/Screens/PhotoDetail/Modifiers/ZoomableModifier.swift b/SnapSafe/Screens/PhotoDetail/Modifiers/ZoomableModifier.swift index 71cfe16..485dafb 100644 --- a/SnapSafe/Screens/PhotoDetail/Modifiers/ZoomableModifier.swift +++ b/SnapSafe/Screens/PhotoDetail/Modifiers/ZoomableModifier.swift @@ -4,69 +4,3 @@ // // Created by Bill Booth on 5/20/25. // - -import SwiftUI - -// View modifier to make a view zoomable and pannable -struct ZoomableModifier: ViewModifier { - @Binding var scale: CGFloat - @Binding var offset: CGSize - @Binding var lastScale: CGFloat - @State private var initialScale: CGFloat = 1.0 - var onZoomOut: () -> Void - var onZoomChange: ((Bool) -> Void)? = nil - - func body(content: Content) -> some View { - content - .scaleEffect(scale) - .offset(offset) - .gesture(makeZoomGesture()) - .gesture(makeDragGesture()) - } - - // Create a pinch/zoom gesture - private func makeZoomGesture() -> some Gesture { - MagnificationGesture() - .onChanged { value in - // Calculate new scale relative to the starting scale - let delta = value / lastScale - lastScale = value - - let newScale = scale * delta - // Limit the scale to reasonable bounds - scale = min(max(newScale, 0.5), 6.0) - - // Call callback when zoom state changes - onZoomChange?(scale > 1.0) - } - .onEnded { _ in - // Reset the lastScale for the next gesture - lastScale = 1.0 - - // If user zoomed out below threshold, trigger dismiss - if scale < 0.6 { - onZoomOut() - } else if scale < 1.0 { - // Spring back to normal size if partially zoomed out - withAnimation(.spring()) { - scale = 1.0 - offset = .zero - } - } - } - } - - // Create a drag gesture for panning - private func makeDragGesture() -> some Gesture { - DragGesture() - .onChanged { value in - // Only enable drag when zoomed in - if scale > 1.0 { - self.offset = CGSize( - width: self.offset.width + value.translation.width, - height: self.offset.height + value.translation.height - ) - } - } - } -} diff --git a/SnapSafe/Screens/PhotoDetail/PagerChromeState.swift b/SnapSafe/Screens/PhotoDetail/PagerChromeState.swift new file mode 100644 index 0000000..6a3a469 --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/PagerChromeState.swift @@ -0,0 +1,18 @@ +// +// PagerChromeState.swift +// SnapSafe +// +// Shared chrome state for the media detail pager. Owned by +// EnhancedPhotoDetailView and injected into each hosted page (via +// .environment) so pages rendered inside UIHostingControllers — like the +// inline video player — can fade their controls while a dismiss drag is in +// flight, matching the page-level photo toolbar. +// + +import Observation + +@MainActor +@Observable +final class PagerChromeState { + var isDismissDragging = false +} diff --git a/SnapSafe/Screens/PhotoDetail/PhotoDetailView.swift b/SnapSafe/Screens/PhotoDetail/PhotoDetailView.swift index 417d2cb..817f9cc 100644 --- a/SnapSafe/Screens/PhotoDetail/PhotoDetailView.swift +++ b/SnapSafe/Screens/PhotoDetail/PhotoDetailView.swift @@ -17,7 +17,6 @@ struct PhotoDetailView: View { // Environment @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var nav: AppNavigationState // Zoom state binding (shared with parent) @Binding var isZoomed: Bool @@ -32,17 +31,6 @@ struct PhotoDetailView: View { _isZoomed = isZoomed } - // Initialize with multiple photos - init(allPhotos: [PhotoDef], initialIndex: Int, onDelete: ((PhotoDef) -> Void)? = nil, onDismiss: (() -> Void)? = nil, isZoomed: Binding = .constant(false)) { - _viewModel = StateObject(wrappedValue: PhotoDetailViewModel( - allPhotos: allPhotos, - initialIndex: initialIndex, - onDelete: onDelete, - onDismiss: onDismiss - )) - _isZoomed = isZoomed - } - var body: some View { GeometryReader { geometry in ZStack { @@ -55,7 +43,7 @@ struct PhotoDetailView: View { if !viewModel.photoFiles.isEmpty { Text("\(viewModel.currentIndex + 1) of \(viewModel.photoFiles.count)") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.top, 8) .opacity(isZoomed ? 0.5 : 1.0) // Fade when zoomed } @@ -81,6 +69,11 @@ struct PhotoDetailView: View { .rotationEffect(Angle(radians: viewModel.imageRotation)) } .frame(maxWidth: .infinity, maxHeight: .infinity) + // Reserve room for the floating action toolbar so the + // image fits ABOVE it. Constant height → the image never + // shifts when paging to/from a video. Collapses while + // zoomed so the photo can use the full screen. + .padding(.bottom, isZoomed ? 0 : PhotoDetailLayout.bottomReserve) } } } diff --git a/SnapSafe/Screens/PhotoDetail/PhotoDetailViewModel.swift b/SnapSafe/Screens/PhotoDetail/PhotoDetailViewModel.swift index bea054e..6e99232 100644 --- a/SnapSafe/Screens/PhotoDetail/PhotoDetailViewModel.swift +++ b/SnapSafe/Screens/PhotoDetail/PhotoDetailViewModel.swift @@ -31,18 +31,11 @@ class PhotoDetailViewModel: ObservableObject { // Zoom and pan states @Published var currentScale: CGFloat = 1.0 - @Published var dragOffset: CGSize = .zero - @Published var lastScale: CGFloat = 1.0 - @Published var isZoomed: Bool = false - @Published var lastDragPosition: CGSize = .zero - + @Published var showImageInfo = false - @Published var isDecoyOperationLoading = false @Published var isPoisonPillConfigured = false // Track currently presented activity controller for dismissal - private weak var currentActivityController: UIActivityViewController? - // MARK: - Dependencies @Injected(\.pinRepository) @@ -54,18 +47,6 @@ class PhotoDetailViewModel: ObservableObject { @Injected(\.authorizationRepository) private var authorizationRepository: AuthorizationRepository - @Injected(\.clock) - private var clock: Clock - - @Injected(\.prepareForSharingUseCase) - private var prepareForSharingUseCase: PrepareForSharingUseCase - - @Injected(\.addDecoyPhotoUseCase) - private var addDecoyPhotoUseCase: AddDecoyPhotoUseCase - - @Injected(\.removeDecoyPhotoUseCase) - private var removeDecoyPhotoUseCase: RemoveDecoyPhotoUseCase - private var cancellables = Set() // MARK: - Initialization @@ -82,19 +63,6 @@ class PhotoDetailViewModel: ObservableObject { } } - init(allPhotos: [PhotoDef], initialIndex: Int, onDelete: ((PhotoDef) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { - self.photoFiles = allPhotos - self.currentIndex = initialIndex - self.onDelete = onDelete - self.onDismiss = onDismiss - setupSecurityObservers() - - // Load the current image immediately - Task { - await loadCurrentImage() - } - } - // MARK: - Computed Properties var currentPhotoDef: PhotoDef? { if !photoFiles.isEmpty { @@ -108,25 +76,10 @@ class PhotoDetailViewModel: ObservableObject { return currentImage ?? UIImage(systemName: "photo")! } - // MARK: - Decoy Management Computed Properties - func isPoisonPillSetup() async -> Bool { return await pinRepository.hasPoisonPillPin() } - - var isCurrentPhotoDecoy: Bool { - guard let photoDef = currentPhotoDef else { return false } - return secureImageRepository.isDecoyPhoto(photoDef) - } - - var decoyButtonTitle: String { - isCurrentPhotoDecoy ? "Remove Decoy" : "Add Decoy" - } - - var decoyButtonIcon: String { - isCurrentPhotoDecoy ? "shield.slash" : "shield" - } - + // MARK: - Image Loading private func loadCurrentImage() async { @@ -135,7 +88,8 @@ class PhotoDetailViewModel: ObservableObject { isImageLoading = true do { - let image = try await secureImageRepository.readImage(photoDef) + let data = try await secureImageRepository.readImage(photoDef) + guard let image = UIImage(data: data) else { throw ImageRepositoryError.invalidImageData } await MainActor.run { self.currentImage = image self.isImageLoading = false @@ -151,15 +105,6 @@ class PhotoDetailViewModel: ObservableObject { } } - var canGoToPrevious: Bool { - !photoFiles.isEmpty && currentIndex > 0 - } - - var canGoToNext: Bool { - !photoFiles.isEmpty && currentIndex < photoFiles.count - 1 - } - - // MARK: - Navigation Methods func preloadAdjacentPhotos() { @@ -188,40 +133,8 @@ class PhotoDetailViewModel: ObservableObject { } } - // MARK: - Image Manipulation - - func resetZoomAndPan() { - withAnimation(.spring()) { - currentScale = 1.0 - dragOffset = .zero - lastScale = 1.0 - isZoomed = false - } - // Reset the last drag position outside of animation to avoid jumps - lastDragPosition = .zero - } - - func rotateImage(direction: Double) { - // Reset any zoom or panning when rotating - resetZoomAndPan() - - // Apply rotation - imageRotation += direction - - // Normalize to 0-360 range - if imageRotation >= 360 { - imageRotation -= 360 - } else if imageRotation < 0 { - imageRotation += 360 - } - } - // MARK: - Photo Management - - func deletePhoto() { - deleteCurrentPhoto() - } - + func deleteCurrentPhoto() { Logger.ui.debug("deleteCurrentPhoto called - starting deletion process") @@ -233,7 +146,7 @@ class PhotoDetailViewModel: ObservableObject { Logger.ui.debug("Attempting to delete file", metadata: [ "filename": .string(photoDefToDelete.photoName) ]) - self.secureImageRepository.deleteImage(photoDefToDelete) + _ = await self.secureImageRepository.deleteImage(photoDefToDelete) Logger.ui.debug("File deletion successful") // All UI updates must happen on the main thread @@ -276,143 +189,6 @@ class PhotoDetailViewModel: ObservableObject { } } - // MARK: - Sharing - - func sharePhoto() { - // Get the current photo image - let image = displayedImage - - // Find the root view controller - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootViewController = window.rootViewController - else { - Logger.ui.error("Could not find root view controller") - return - } - - // Find the presented view controller to present from - var currentController = rootViewController - while let presented = currentController.presentedViewController { - currentController = presented - } - - // Convert image to data for sharing with UUID filename - if let imageData = image.jpegData(compressionQuality: 0.9) { - do { - // Prepare photo for sharing with UUID filename - let fileURL = try prepareForSharingUseCase.preparePhotoForSharing(imageData: imageData) - - Logger.ui.debug("Sharing photo with UUID filename", metadata: [ - "filename": .string(fileURL.lastPathComponent) - ]) - - // Create a UIActivityViewController to show the sharing options with the file - let activityViewController = UIActivityViewController( - activityItems: [fileURL], - applicationActivities: nil - ) - - // For iPad support - if let popover = activityViewController.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - // Store reference and present the share sheet - currentActivityController = activityViewController - Task { @MainActor in - currentController.present(activityViewController, animated: true) { - Logger.ui.debug("Share sheet presented successfully") - } - } - } catch { - Logger.ui.error("Error preparing photo for sharing", metadata: [ - "error": .string(error.localizedDescription) - ]) - - // Fallback to sharing just the image if file preparation fails - let activityViewController = UIActivityViewController( - activityItems: [image], - applicationActivities: nil - ) - - // For iPad support - if let popover = activityViewController.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - // Store reference and present the share sheet - currentActivityController = activityViewController - Task { @MainActor in - currentController.present(activityViewController, animated: true) { - Logger.ui.debug("Share sheet presented successfully (image fallback)") - } - } - } - } else { - // Fallback to sharing just the image if data conversion fails - let activityViewController = UIActivityViewController( - activityItems: [image], - applicationActivities: nil - ) - - // For iPad support - if let popover = activityViewController.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - // Store reference and present the share sheet - currentActivityController = activityViewController - Task { @MainActor in - currentController.present(activityViewController, animated: true) { - Logger.ui.debug("Share sheet presented successfully (image fallback)") - } - } - } - } - - // MARK: - Decoy Management - - func toggleDecoyStatus() { - guard let photoDef = currentPhotoDef else { return } - - isDecoyOperationLoading = true - - Task(priority: .userInitiated) { - if isCurrentPhotoDecoy { - // Remove decoy status - await MainActor.run { - _ = removeDecoyPhotoUseCase.removeDecoyPhoto(photoDef) - isDecoyOperationLoading = false - Logger.ui.info("Removed photo from decoys", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - } - } else { - // Add decoy status - let success = await addDecoyPhotoUseCase.addDecoyPhoto(photoDef: photoDef) - await MainActor.run { - isDecoyOperationLoading = false - if success { - Logger.ui.info("Added photo as decoy", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - } else { - Logger.ui.error("Failed to add photo as decoy", metadata: [ - "photoName": .string(photoDef.photoName) - ]) - } - } - } - } - } - // MARK: - View Lifecycle func onAppear() { @@ -459,8 +235,5 @@ class PhotoDetailViewModel: ObservableObject { showDeleteConfirmation = false showImageInfo = false - // Dismiss any currently presented activity controller (iOS export dialog) - currentActivityController?.dismiss(animated: false, completion: nil) - currentActivityController = nil } } diff --git a/SnapSafe/Screens/PhotoDetail/PhotoPageViewController.swift b/SnapSafe/Screens/PhotoDetail/PhotoPageViewController.swift index 2995bbd..43c362e 100644 --- a/SnapSafe/Screens/PhotoDetail/PhotoPageViewController.swift +++ b/SnapSafe/Screens/PhotoDetail/PhotoPageViewController.swift @@ -7,24 +7,49 @@ import SwiftUI import UIKit +import AVKit +import CryptoKit import Logging struct PhotoPageViewController: UIViewControllerRepresentable { // MARK: - Inputs - let photos: [PhotoDef] + let allMedia: [GalleryMediaItem] @Binding var currentIndex: Int @Binding var isZoomed: Bool + /// Shared chrome state injected into hosted pages so they can fade their + /// controls during a dismiss drag. + let chromeState: PagerChromeState + /// True while a dismiss drag is engaged; horizontal paging is disabled so + /// the pager can't start a page transition mid-dismiss. + let isDismissDragging: Bool + /// Invoked when a video page deletes its video, so the detail view can pop. + let onRequestDismiss: () -> Void + /// Invoked when a video page's Info button is tapped, with that page's video. + let onVideoInfo: (VideoDef) -> Void + /// Invoked by inline video pages when their glass controls show/hide, so + /// the photo counter chip overlay can fade together with them. + let onVideoControlsVisibilityChange: (Bool) -> Void // MARK: - Init init( - photos: [PhotoDef], + allMedia: [GalleryMediaItem], currentIndex: Binding, - isZoomed: Binding + isZoomed: Binding, + chromeState: PagerChromeState, + isDismissDragging: Bool, + onRequestDismiss: @escaping () -> Void, + onVideoInfo: @escaping (VideoDef) -> Void = { _ in }, + onVideoControlsVisibilityChange: @escaping (Bool) -> Void = { _ in } ) { - self.photos = photos + self.allMedia = allMedia self._currentIndex = currentIndex self._isZoomed = isZoomed + self.chromeState = chromeState + self.isDismissDragging = isDismissDragging + self.onRequestDismiss = onRequestDismiss + self.onVideoInfo = onVideoInfo + self.onVideoControlsVisibilityChange = onVideoControlsVisibilityChange } // MARK: - UIViewControllerRepresentable @@ -39,7 +64,7 @@ struct PhotoPageViewController: UIViewControllerRepresentable { pageVC.delegate = context.coordinator pageVC.view.backgroundColor = .clear - if currentIndex < photos.count { + if currentIndex < allMedia.count { let initialVC = context.coordinator.viewController(at: currentIndex) pageVC.setViewControllers( [initialVC], @@ -50,70 +75,108 @@ struct PhotoPageViewController: UIViewControllerRepresentable { if let scrollView = pageVC.view.subviews.compactMap({ $0 as? UIScrollView }).first { context.coordinator.pageScrollView = scrollView - context.coordinator.setupGestureCoordination(scrollView: scrollView) } return pageVC } func updateUIViewController(_ uiViewController: UIPageViewController, context: Context) { - context.coordinator.photos = photos + context.coordinator.allMedia = allMedia context.coordinator.currentIndexBinding = _currentIndex context.coordinator.isZoomedBinding = _isZoomed + context.coordinator.isDismissDragging = isDismissDragging + context.coordinator.onRequestDismiss = onRequestDismiss + context.coordinator.onVideoInfo = onVideoInfo + context.coordinator.onVideoControlsVisibilityChange = onVideoControlsVisibilityChange context.coordinator.updatePagingEnabled() } func makeCoordinator() -> Coordinator { Coordinator( - photos: photos, + allMedia: allMedia, currentIndexBinding: _currentIndex, - isZoomedBinding: _isZoomed + isZoomedBinding: _isZoomed, + chromeState: chromeState, + onRequestDismiss: onRequestDismiss, + onVideoInfo: onVideoInfo, + onVideoControlsVisibilityChange: onVideoControlsVisibilityChange ) } // MARK: - Coordinator final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { - var photos: [PhotoDef] + var allMedia: [GalleryMediaItem] var currentIndexBinding: Binding var isZoomedBinding: Binding + var isDismissDragging = false + let chromeState: PagerChromeState + var onRequestDismiss: () -> Void + var onVideoInfo: (VideoDef) -> Void + var onVideoControlsVisibilityChange: (Bool) -> Void weak var pageScrollView: UIScrollView? - private var viewControllerCache: [Int: PhotoDetailHostingController] = [:] + private var viewControllerCache: [Int: UIViewController] = [:] - init(photos: [PhotoDef], currentIndexBinding: Binding, isZoomedBinding: Binding) { - self.photos = photos + init( + allMedia: [GalleryMediaItem], + currentIndexBinding: Binding, + isZoomedBinding: Binding, + chromeState: PagerChromeState, + onRequestDismiss: @escaping () -> Void, + onVideoInfo: @escaping (VideoDef) -> Void, + onVideoControlsVisibilityChange: @escaping (Bool) -> Void + ) { + self.allMedia = allMedia self.currentIndexBinding = currentIndexBinding self.isZoomedBinding = isZoomedBinding + self.chromeState = chromeState + self.onRequestDismiss = onRequestDismiss + self.onVideoInfo = onVideoInfo + self.onVideoControlsVisibilityChange = onVideoControlsVisibilityChange } // MARK: - View Controller Management - func viewController(at index: Int) -> PhotoDetailHostingController { + func viewController(at index: Int) -> UIViewController { if let cached = viewControllerCache[index] { return cached } - let photo = photos[index] - let vc = PhotoDetailHostingController( - photo: photo, - isZoomed: isZoomedBinding - ) - vc.view.backgroundColor = .clear + let item = allMedia[index] + let vc: UIViewController - viewControllerCache[index] = vc + if let photoDef = item.photoDef { + let hostingVC = PhotoDetailHostingController( + photo: photoDef, + isZoomed: isZoomedBinding + ) + vc = hostingVC + } else if let videoDef = item.videoDef { + let hostingVC = InlineVideoHostingController( + videoDef: videoDef, + encryptionKey: item.encryptionKey, + isZoomed: isZoomedBinding, + chromeState: chromeState, + onRequestDismiss: onRequestDismiss, + onInfo: { [weak self] in self?.onVideoInfo(videoDef) }, + onControlsVisibilityChange: { [weak self] visible in + self?.onVideoControlsVisibilityChange(visible) + } + ) + vc = hostingVC + } else { + // Fallback: empty black page + let fallback = UIViewController() + fallback.view.backgroundColor = .black + vc = fallback + } + vc.view.backgroundColor = .clear + viewControllerCache[index] = vc return vc } - // MARK: - Gesture Coordination - func setupGestureCoordination(scrollView: UIScrollView) { - // The page scroll view's pan gesture will automatically be coordinated - // with the zoom scroll view's pan gesture by UIKit's gesture system - // We're doing this all in UIkit - } - // MARK: - Paging Control func updatePagingEnabled() { - // Disable paging when zoomed to allow free panning in all directions - pageScrollView?.isScrollEnabled = !isZoomedBinding.wrappedValue + pageScrollView?.isScrollEnabled = !isZoomedBinding.wrappedValue && !isDismissDragging } // MARK: - UIPageViewControllerDataSource @@ -121,8 +184,7 @@ struct PhotoPageViewController: UIViewControllerRepresentable { _ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController ) -> UIViewController? { - guard let vc = viewController as? PhotoDetailHostingController, - let index = viewControllerCache.first(where: { $0.value === vc })?.key, + guard let index = viewControllerCache.first(where: { $0.value === viewController })?.key, index > 0 else { return nil } @@ -133,9 +195,8 @@ struct PhotoPageViewController: UIViewControllerRepresentable { _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController ) -> UIViewController? { - guard let vc = viewController as? PhotoDetailHostingController, - let index = viewControllerCache.first(where: { $0.value === vc })?.key, - index < photos.count - 1 else { + guard let index = viewControllerCache.first(where: { $0.value === viewController })?.key, + index < allMedia.count - 1 else { return nil } return self.viewController(at: index + 1) @@ -149,12 +210,11 @@ struct PhotoPageViewController: UIViewControllerRepresentable { transitionCompleted completed: Bool ) { guard completed, - let visibleVC = pageViewController.viewControllers?.first as? PhotoDetailHostingController, + let visibleVC = pageViewController.viewControllers?.first, let newIndex = viewControllerCache.first(where: { $0.value === visibleVC })?.key else { return } - // Update binding on main thread DispatchQueue.main.async { self.isZoomedBinding.wrappedValue = false self.currentIndexBinding.wrappedValue = newIndex @@ -165,7 +225,8 @@ struct PhotoPageViewController: UIViewControllerRepresentable { } } -// MARK: - Hosting Controller for PhotoDetailView +// MARK: - Hosting Controller for a single photo page + class PhotoDetailHostingController: UIHostingController { init(photo: PhotoDef, isZoomed: Binding) { let view = PhotoDetailView( @@ -181,3 +242,49 @@ class PhotoDetailHostingController: UIHostingController { fatalError("init(coder:) has not been implemented") } } + +// MARK: - Hosting Controller for an inline video page + +class InlineVideoHostingController: UIHostingController { + private let viewModel: VideoPlayerViewModel + + init( + videoDef: VideoDef, + encryptionKey: SymmetricKey?, + isZoomed: Binding, + chromeState: PagerChromeState, + onRequestDismiss: @escaping () -> Void, + onInfo: @escaping () -> Void, + onControlsVisibilityChange: @escaping (Bool) -> Void + ) { + let viewModel = VideoPlayerViewModel(videoDef: videoDef, encryptionKey: encryptionKey) + self.viewModel = viewModel + let view = InlineVideoPlayerView( + viewModel: viewModel, + isZoomed: isZoomed, + onRequestDismiss: onRequestDismiss, + onInfo: onInfo, + onControlsVisibilityChange: onControlsVisibilityChange + ) + super.init(rootView: AnyView(view.environment(chromeState))) + } + + // Playback is driven from UIKit appearance callbacks rather than SwiftUI's + // onAppear/onDisappear: UIPageViewController keeps adjacent pages attached + // to the window, so a swiped-away page never gets onDisappear and its + // video would keep playing (audibly) off-screen. + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.setupPlayback() + viewModel.showAndScheduleHideControls() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + viewModel.cleanup() + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/SnapSafe/Screens/PhotoDetail/VideoInfoView.swift b/SnapSafe/Screens/PhotoDetail/VideoInfoView.swift new file mode 100644 index 0000000..79e89c9 --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/VideoInfoView.swift @@ -0,0 +1,86 @@ +// +// VideoInfoView.swift +// SnapSafe +// +// Displays metadata for a video. Mirrors ImageInfoView, with a Video technical +// section (duration, codec, frame rate, bitrate) in place of the Camera section. +// + +import SwiftUI + +struct VideoInfoView: View { + @StateObject private var viewModel: VideoInfoViewModel + @Environment(\.dismiss) private var dismiss + + init(videoDef: VideoDef) { + _viewModel = StateObject(wrappedValue: VideoInfoViewModel(videoDef: videoDef)) + } + + var body: some View { + if viewModel.isLoading { + ProgressView("Loading video information...") + .navigationTitle("Video Information") + .navigationBarTitleDisplayMode(.inline) + .toolbar { doneButton } + } else { + Form { + if let errorMessage = viewModel.errorMessage { + Section { + Text(errorMessage) + .foregroundStyle(.secondary) + } + } else { + Section(header: Text("Basic Information")) { + infoRow("Filename", viewModel.filename) + infoRow("Resolution", viewModel.resolution) + infoRow("File Size", viewModel.fileSize) + } + + Section(header: Text("Date Information")) { + infoRow("Date Taken", viewModel.dateTaken) + if viewModel.isDateFromFilename { + Text("(from filename)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section(header: Text("Orientation")) { + infoRow("Orientation", viewModel.orientation) + } + + Section(header: Text("Location")) { + Text(viewModel.location) + .foregroundStyle(.secondary) + } + + Section(header: Text("Video Information")) { + infoRow("Duration", viewModel.duration) + infoRow("Codec", viewModel.codec) + infoRow("Frame Rate", viewModel.frameRate) + infoRow("Bitrate", viewModel.bitrate) + } + } + } + .navigationTitle("Video Information") + .navigationBarTitleDisplayMode(.inline) + .toolbar { doneButton } + } + } + + @ToolbarContentBuilder + private var doneButton: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + + private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + } + } +} diff --git a/SnapSafe/Screens/PhotoDetail/VideoInfoViewModel.swift b/SnapSafe/Screens/PhotoDetail/VideoInfoViewModel.swift new file mode 100644 index 0000000..a209a8d --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/VideoInfoViewModel.swift @@ -0,0 +1,57 @@ +// +// VideoInfoViewModel.swift +// SnapSafe +// +// Loads VideoMetaData for a video and exposes display strings for VideoInfoView. +// Mirrors ImageInfoViewModel. +// + +import SwiftUI +import FactoryKit +import Logging + +@MainActor +final class VideoInfoViewModel: ObservableObject { + private let videoDef: VideoDef + + @Injected(\.secureImageRepository) + private var secureImageRepository: SecureImageRepository + + @Published var metadata: VideoMetaData? + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + init(videoDef: VideoDef) { + self.videoDef = videoDef + Task { await loadMetadata() } + } + + // MARK: - Display strings + + var filename: String { videoDef.videoName } + + var resolution: String { metadata?.resolutionString ?? VideoMetaData.unavailable } + var fileSize: String { metadata?.fileSizeString ?? VideoMetaData.unavailable } + var dateTaken: String { metadata?.dateTakenString ?? VideoMetaData.unavailable } + var isDateFromFilename: Bool { metadata?.isDateFromFilename ?? false } + var orientation: String { metadata?.orientationString ?? VideoMetaData.unavailable } + var location: String { metadata?.locationString ?? VideoMetaData.unavailable } + var duration: String { metadata?.durationString ?? VideoMetaData.unavailable } + var codec: String { metadata?.codecString ?? VideoMetaData.unavailable } + var frameRate: String { metadata?.frameRateString ?? VideoMetaData.unavailable } + var bitrate: String { metadata?.bitrateString ?? VideoMetaData.unavailable } + + // MARK: - Loading + + private func loadMetadata() async { + isLoading = true + do { + metadata = try await secureImageRepository.getVideoMetaData(videoDef) + errorMessage = nil + } catch { + Logger.storage.error("Error loading video metadata: \(error)") + errorMessage = "Could not load video information." + } + isLoading = false + } +} diff --git a/SnapSafe/Screens/PhotoDetail/VideoPlayerView.swift b/SnapSafe/Screens/PhotoDetail/VideoPlayerView.swift new file mode 100644 index 0000000..2c0fcee --- /dev/null +++ b/SnapSafe/Screens/PhotoDetail/VideoPlayerView.swift @@ -0,0 +1,626 @@ +// +// VideoPlayerView.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import SwiftUI +import AVKit +import Combine +import CryptoKit +import FactoryKit +import Logging + +/// Video player view for playing both encrypted and unencrypted videos. +struct VideoPlayerView: View { + @StateObject private var viewModel: VideoPlayerViewModel + @EnvironmentObject private var nav: AppNavigationState + + init(videoDef: VideoDef, encryptionKey: SymmetricKey?) { + _viewModel = StateObject(wrappedValue: VideoPlayerViewModel(videoDef: videoDef, encryptionKey: encryptionKey)) + } + + var body: some View { + ZStack { + // Black background fills entire screen including safe area + Color.black.ignoresSafeArea() + + // Video player fills screen + if let player = viewModel.player { + VideoPlayer(player: player) + .ignoresSafeArea() + .onDisappear { + viewModel.cleanup() + } + } else if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + } else if let error = viewModel.error { + ErrorView(error: error, onRetry: { + viewModel.retryPlayback() + }) + } + + // Overlay controls - respects safe area + VStack { + // Top bar with back button + HStack { + Button(action: { + viewModel.cleanup() + nav.navigateBack() + }) { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundStyle(.white) + .padding(12) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .padding(.leading) + + Spacer() + } + .padding(.top, 8) + + Spacer() + + // Bottom controls + if viewModel.showControls { + HStack { + Button(action: { + viewModel.togglePlayback() + }) { + Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + .font(.title) + .foregroundStyle(.white) + .padding() + } + + if let duration = viewModel.duration { + ProgressView(value: viewModel.currentTime, total: duration) + .tint(.white) + .frame(height: 4) + .padding(.horizontal) + } + + if let duration = viewModel.duration { + Text("\(viewModel.currentTime.formattedTime) / \(duration.formattedTime)") + .foregroundStyle(.white) + .font(.caption) + .monospacedDigit() + .padding(.trailing) + } + } + .padding(.vertical, 8) + .background(Color.black.opacity(0.5)) + .transition(.move(edge: .bottom)) + } + } + .animation(.easeInOut, value: viewModel.showControls) + .sensoryFeedback(.impact(weight: .light), trigger: viewModel.isPlaying) + } + .onTapGesture { + viewModel.toggleControls() + } + .onAppear { + viewModel.setupPlayback() + } + .navigationBarHidden(true) + } + + // Helper view for error display + private struct ErrorView: View { + let error: Error + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundStyle(.white) + + Text("Playback Error") + .font(.title) + .foregroundStyle(.white) + + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 30) + + Button(action: onRetry) { + Text("Retry") + .font(.headline) + .foregroundStyle(.black) + .padding(.horizontal, 30) + .padding(.vertical, 10) + .background(Color.white) + .clipShape(.rect(cornerRadius: 8)) + } + } + } + } +} + +// MARK: - ViewModel + +@MainActor +final class VideoPlayerViewModel: ObservableObject { + let videoDef: VideoDef + let encryptionKey: SymmetricKey? + + @Injected(\.secureImageRepository) private var secureImageRepository: SecureImageRepository + @Injected(\.addDecoyVideoUseCase) private var addDecoyVideoUseCase: AddDecoyVideoUseCase + @Injected(\.videoEncryptionService) private var videoEncryptionService: VideoEncryptionService + @Injected(\.pinRepository) private var pinRepository: PinRepository + + @Published var player: AVPlayer? + @Published var isLoading = true + @Published var isPlaying = false + @Published var showControls = true + @Published var currentTime: TimeInterval = 0 + @Published var duration: TimeInterval? = nil + @Published var error: Error? = nil + @Published var isScrubbing = false + /// True once the video has played to the end, so the transport can show a + /// replay affordance instead of play/pause. + @Published var didPlayToEnd = false + @Published var isMuted = false + + // Gallery action state (used by the inline detail player) + @Published var isPoisonPillConfigured = false + @Published var isDecoy = false + @Published var isDecoyOperationLoading = false + @Published var showDecoyLimitAlert = false + + var decoyButtonTitle: String { isDecoy ? "Remove Decoy" : "Add Decoy" } + var decoyButtonIcon: String { isDecoy ? "shield.slash" : "shield" } + + private var timeObserver: Any? + private var cancellables = Set() + private var hideControlsTask: Task? + private var loadTask: Task? + private let controlsAutoHideDelay: TimeInterval = 5 + + init(videoDef: VideoDef, encryptionKey: SymmetricKey?) { + self.videoDef = videoDef + self.encryptionKey = encryptionKey + } + + // cleanup() is called from onDisappear in VideoPlayerView + + // MARK: - Public Methods + + func setupPlayback() { + // A loader is already in flight or has finished — don't stack a + // second AVPlayer that would race the first. + guard player == nil, loadTask == nil else { return } + + // Use `.playback`/`.moviePlayback` so the video plays audibly even + // when the phone's ringer switch is in silent — matching the Photos + // app. Without this we inherit the iOS default `.soloAmbient`, which + // respects the silent switch and produces a silent playback that + // reads as "no audio was recorded." + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + } catch { + logger.warning("Failed to configure audio session for playback", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + + // A page that was cleaned up (swiped away) and is coming back has + // player == nil; show the spinner while we reload rather than a blank page. + isLoading = true + didPlayToEnd = false + + loadTask = Task { [weak self] in + await self?.loadVideoAsset() + // A cancelled load was already cleared by cleanup(); don't clobber + // a newer load that may have started since. + guard !Task.isCancelled else { return } + await MainActor.run { self?.loadTask = nil } + } + } + + func cleanup() { + hideControlsTask?.cancel() + hideControlsTask = nil + // Cancel any in-flight asset load so a slow decrypt can't auto-play + // after the page has been swiped away. + loadTask?.cancel() + loadTask = nil + cancellables.removeAll() + if let timeObserver = timeObserver { + player?.removeTimeObserver(timeObserver) + self.timeObserver = nil + } + + player?.pause() + isPlaying = false + player = nil + + // Hand the audio session back so other audio (Music, podcasts) can + // resume. Best-effort: log and move on if iOS refuses. + do { + try AVAudioSession.sharedInstance().setActive( + false, options: [.notifyOthersOnDeactivation] + ) + } catch { + logger.debug("Audio session deactivate failed", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + + func togglePlayback() { + if isPlaying { + player?.pause() + } else { + player?.play() + } + isPlaying = !isPlaying + didPlayToEnd = false + scheduleHideControls() + } + + /// Restarts playback from the beginning. Used by the replay affordance the + /// transport shows once the video has played to the end. + func replay() { + guard let player else { return } + didPlayToEnd = false + player.seek(to: .zero) + player.play() + isPlaying = true + scheduleHideControls() + } + + func toggleMute() { + isMuted.toggle() + player?.isMuted = isMuted + scheduleHideControls() + } + + func retryPlayback() { + error = nil + isLoading = true + setupPlayback() + } + + func toggleControls() { + showControls.toggle() + if showControls { + scheduleHideControls() + } else { + hideControlsTask?.cancel() + } + } + + /// Shows the controls and (re)starts the auto-hide countdown. Call this + /// whenever the user interacts with the controls so they stay visible + /// long enough to be useful. + func showAndScheduleHideControls() { + if !showControls { + withAnimation(.easeInOut(duration: 0.2)) { + showControls = true + } + } + scheduleHideControls() + } + + /// Cancels any pending auto-hide. Use while the user is actively + /// scrubbing so controls don't vanish mid-drag. + func cancelHideControls() { + hideControlsTask?.cancel() + } + + private func scheduleHideControls() { + hideControlsTask?.cancel() + let delay = controlsAutoHideDelay + hideControlsTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + guard self.showControls, self.isPlaying, !self.isScrubbing else { return } + withAnimation(.easeInOut(duration: 0.2)) { + self.showControls = false + } + } + } + + // MARK: - Private Methods + + private func loadVideoAsset() async { + do { + let asset: AVAsset + + if videoDef.isEncrypted { + guard let encryptionKey = encryptionKey else { + throw SECVError.decryptionFailed + } + + guard let encryptedAsset = AVAsset.makeEncryptedVideoAsset(with: videoDef.videoFile, encryptionKey: encryptionKey) else { + throw SECVError.decryptionFailed + } + + asset = encryptedAsset + } else { + // For unencrypted videos, use regular AVAsset + asset = AVURLAsset(url: videoDef.videoFile) + } + + // Load asset metadata + await loadAssetMetadata(asset) + + // Create player item and player + let playerItem = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: playerItem) + + // Setup time observer + setupTimeObserver(for: player) + + // Setup player item observers + setupPlayerItemObservers(for: playerItem) + + // Bail if the page was swiped away (or the model torn down) + // while we were decrypting / loading — otherwise we'd attach a + // fresh player and play audio off-screen. + if Task.isCancelled { + player.pause() + return + } + + // Update state + await MainActor.run { + guard !Task.isCancelled else { + player.pause() + return + } + self.player = player + self.isLoading = false + // Carry the current mute state onto the freshly created player. + player.isMuted = self.isMuted + + let autoPlay = UserDefaults.standard.object(forKey: "autoPlayVideos") as? Bool ?? false + if autoPlay { + player.play() + self.isPlaying = true + } else { + self.isPlaying = false + } + self.scheduleHideControls() + } + + } catch { + await MainActor.run { + self.error = error + self.isLoading = false + logger.error("Failed to load video asset", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + } + + private func loadAssetMetadata(_ asset: AVAsset) async { + do { + // Load duration + let duration = try await asset.load(.duration) + await MainActor.run { + self.duration = duration.seconds + } + + // Load other metadata as needed + let tracks = try await asset.load(.tracks) + logger.debug("Video asset loaded", metadata: [ + "duration": .stringConvertible(duration.seconds), + "trackCount": .stringConvertible(tracks.count) + ]) + + } catch { + logger.error("Failed to load asset metadata", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + } + + private func setupTimeObserver(for player: AVPlayer) { + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main) { [weak self] time in + Task { @MainActor [weak self] in + guard let self, !self.isScrubbing else { return } + self.currentTime = time.seconds + } + } + } + + private func setupPlayerItemObservers(for playerItem: AVPlayerItem) { + // Observe playback status + playerItem.publisher(for: \.status) + .sink { [weak self] status in + guard let self = self else { return } + + switch status { + case .readyToPlay: + self.isLoading = false + logger.debug("Player item ready to play") + + case .failed: + if let error = playerItem.error { + self.error = error + logger.error("Player item failed", metadata: [ + "error": .string(error.localizedDescription) + ]) + } + + case .unknown: + logger.debug("Player item status unknown") + + @unknown default: + break + } + } + .store(in: &cancellables) + + // Observe playback completion + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: playerItem) + .sink { [weak self] _ in + guard let self = self else { return } + self.isPlaying = false + self.didPlayToEnd = true + self.showControls = true + logger.debug("Playback completed") + } + .store(in: &cancellables) + } + + // MARK: - Scrubbing + + func beginScrubbing() { + isScrubbing = true + player?.pause() + cancelHideControls() + } + + /// Updates the displayed time as the user drags, without committing a seek. + func scrub(toFraction fraction: Double) { + guard let duration else { return } + currentTime = max(0, min(duration, duration * fraction)) + } + + /// Commits the seek and resumes playback if it was playing. + func endScrubbing(atFraction fraction: Double) { + guard let duration, let player else { isScrubbing = false; return } + let target = max(0, min(duration, duration * fraction)) + currentTime = target + if target < duration { didPlayToEnd = false } + player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.isScrubbing = false + if self.isPlaying { self.player?.play() } + self.scheduleHideControls() + } + } + } + + // MARK: - Gallery Actions (inline detail player) + + func loadActionState() { + Task { + let decoy = await secureImageRepository.isDecoyVideo(videoDef) + let configured = await pinRepository.hasPoisonPillPin() + await MainActor.run { + self.isDecoy = decoy + self.isPoisonPillConfigured = configured + } + } + } + + func toggleDecoy() { + isDecoyOperationLoading = true + Task { + if isDecoy { + _ = await secureImageRepository.removeDecoyVideo(videoDef) + await MainActor.run { + self.isDecoy = false + self.isDecoyOperationLoading = false + } + } else { + // Pre-check the limit so the limit alert only shows when actually + // at the limit, not for an unrelated failure. The use case enforces + // the same limit authoritatively. + guard await secureImageRepository.numDecoys() < SecureImageRepository.maxDecoyItems else { + await MainActor.run { + self.isDecoyOperationLoading = false + self.showDecoyLimitAlert = true + } + return + } + let success = await addDecoyVideoUseCase.addDecoyVideo(videoDef: videoDef) + await MainActor.run { + self.isDecoy = success + self.isDecoyOperationLoading = false + } + if !success { logger.error("Failed to add video decoy") } + } + } + } + + func share() { + Task { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("share_\(videoDef.videoName).mov") + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + do { + if videoDef.isEncrypted, let key = encryptionKey { + try await videoEncryptionService.decryptVideoForSharing( + inputURL: videoDef.videoFile, outputURL: tempURL, encryptionKey: key) + } else { + try? FileManager.default.removeItem(at: tempURL) + try FileManager.default.copyItem(at: videoDef.videoFile, to: tempURL) + } + await MainActor.run { self.presentShareSheet(with: [tempURL]) } + } catch { + logger.error("Failed to prepare video for sharing", metadata: [ + "error": .string(error.localizedDescription)]) + } + } + } + + /// Deletes the video and its derived files. The caller dismisses the detail view. + func deleteVideo() { + cleanup() + try? FileManager.default.removeItem(at: videoDef.videoFile) + Task { + await secureImageRepository.deleteVideoThumbnail(forVideoNamed: videoDef.videoName) + _ = await secureImageRepository.removeDecoyVideo(videoDef) + } + } + + private func presentShareSheet(with items: [Any]) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = windowScene.windows.first?.rootViewController else { return } + var presenter = root + while let presented = presenter.presentedViewController { + presenter = presented + } + let ac = UIActivityViewController(activityItems: items, applicationActivities: nil) + if let popover = ac.popoverPresentationController { + popover.sourceView = presenter.view + popover.sourceRect = CGRect(x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, width: 0, height: 0) + popover.permittedArrowDirections = [] + } + presenter.present(ac, animated: true) + } + + private let logger = Logger.video +} + +// MARK: - TimeInterval Extension + +extension TimeInterval { + var formattedTime: String { + let totalSeconds = Int(self) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%d:%02d", minutes, seconds) + } + } +} + +// NOTE: AVPlayerItem status observation uses Foundation's built-in, thread-safe +// `NSObject.publisher(for:options:)` (default options `[.initial, .new]`). A custom +// Combine `Publisher`/`Subscription` used to live here, but it was `@unchecked +// Sendable` and mutated its subscription state in `cancel()` without synchronization +// while the KVO callback could fire concurrently — a data race. The built-in KVO +// publisher provides the same semantics safely. diff --git a/SnapSafe/Screens/PhotoDetail/ZoomableScrollView.swift b/SnapSafe/Screens/PhotoDetail/ZoomableScrollView.swift index 080f46b..eeb94ae 100644 --- a/SnapSafe/Screens/PhotoDetail/ZoomableScrollView.swift +++ b/SnapSafe/Screens/PhotoDetail/ZoomableScrollView.swift @@ -9,33 +9,39 @@ import Foundation import SwiftUI import UIKit -public struct ZoomableScrollView: UIViewRepresentable { +struct ZoomableScrollView: UIViewRepresentable { // MARK: – Inputs private let minZoom: CGFloat private let maxZoom: CGFloat private let showsIndicators: Bool + /// Optional single-tap callback. When set, a tap recognizer is installed + /// that waits for the double-tap (zoom) recognizer to fail, so a double + /// tap never also fires the single-tap action. + private let onSingleTap: (() -> Void)? private let content: Content // MARK: – Zoom surfaced to SwiftUI @Binding private var isZoomed: Bool // MARK: – Init - public init( + init( minZoom: CGFloat = 1.0, maxZoom: CGFloat = 4.0, showsIndicators: Bool = false, isZoomed: Binding, + onSingleTap: (() -> Void)? = nil, @ViewBuilder content: () -> Content ) { self.minZoom = minZoom self.maxZoom = maxZoom self.showsIndicators = showsIndicators self._isZoomed = isZoomed + self.onSingleTap = onSingleTap self.content = content() } // MARK: – UIViewRepresentable - public func makeUIView(context: Context) -> UIScrollView { + func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = showsIndicators scrollView.showsHorizontalScrollIndicator = showsIndicators @@ -51,9 +57,6 @@ public struct ZoomableScrollView: UIViewRepresentable { // Enable simultaneous pan and pinch gestures (allows 2-finger pan during/after pinch) scrollView.panGestureRecognizer.maximumNumberOfTouches = 2 - // Store reference to coordinator for bounds observation - context.coordinator.scrollView = scrollView - let hosted = context.coordinator.hostingController hosted.view.backgroundColor = .clear hosted.view.translatesAutoresizingMaskIntoConstraints = false @@ -88,10 +91,22 @@ public struct ZoomableScrollView: UIViewRepresentable { doubleTap.numberOfTapsRequired = 2 scrollView.addGestureRecognizer(doubleTap) + context.coordinator.onSingleTap = onSingleTap + if onSingleTap != nil { + let singleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleSingleTap(_:)) + ) + singleTap.numberOfTapsRequired = 1 + singleTap.require(toFail: doubleTap) + scrollView.addGestureRecognizer(singleTap) + } + return scrollView } - public func updateUIView(_ uiView: UIScrollView, context: Context) { + func updateUIView(_ uiView: UIScrollView, context: Context) { + context.coordinator.onSingleTap = onSingleTap context.coordinator.hostingController.rootView = content let atMin = abs(uiView.zoomScale - uiView.minimumZoomScale) < 0.01 @@ -109,32 +124,32 @@ public struct ZoomableScrollView: UIViewRepresentable { } } - public func makeCoordinator() -> Coordinator { + func makeCoordinator() -> Coordinator { Coordinator(isZoomed: _isZoomed, content: content) } // MARK: – Coordinator - public final class Coordinator: NSObject, UIScrollViewDelegate { + final class Coordinator: NSObject, UIScrollViewDelegate { fileprivate let hostingController: UIHostingController private var isZoomedBinding: Binding private var isZooming: Bool = false - weak var scrollView: UIScrollView? var lastBoundsSize: CGSize = .zero + var onSingleTap: (() -> Void)? internal init(isZoomed: Binding, content: Content) { self.hostingController = UIHostingController(rootView: content) self.isZoomedBinding = isZoomed } - public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { hostingController.view } - public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { isZooming = true } - public func scrollViewDidZoom(_ scrollView: UIScrollView) { + func scrollViewDidZoom(_ scrollView: UIScrollView) { let atMin = abs(scrollView.zoomScale - scrollView.minimumZoomScale) < 0.01 let newZoomState = !atMin @@ -146,7 +161,7 @@ public struct ZoomableScrollView: UIViewRepresentable { // Don't adjust content insets during zoom - let UIKit handle the anchor point } - public func scrollViewDidEndZooming( + func scrollViewDidEndZooming( _ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat @@ -155,17 +170,22 @@ public struct ZoomableScrollView: UIViewRepresentable { centerContentIfNeeded(scrollView) } - public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { centerContentIfNeeded(scrollView) } - public func scrollViewDidScroll(_ scrollView: UIScrollView) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { // Only adjust centering when not actively zooming if !isZooming { centerContentIfNeeded(scrollView) } } + // MARK: – Single Tap + @objc internal func handleSingleTap(_: UITapGestureRecognizer) { + onSingleTap?() + } + // MARK: – Double Tap Zoom @objc internal func handleDoubleTap(_ gesture: UITapGestureRecognizer) { guard let scrollView = gesture.view as? UIScrollView else { return } @@ -186,7 +206,7 @@ public struct ZoomableScrollView: UIViewRepresentable { // Handle bounds changes (e.g., rotation) fileprivate func handleBoundsChange(_ scrollView: UIScrollView) { - guard let view = hostingController.view else { return } + guard hostingController.view != nil else { return } // If zoomed, maintain the center point if scrollView.zoomScale > scrollView.minimumZoomScale { diff --git a/SnapSafe/Screens/PhotoObfuscation/Components/FaceBoxView.swift b/SnapSafe/Screens/PhotoObfuscation/Components/FaceBoxView.swift index c7cfeee..6eea191 100644 --- a/SnapSafe/Screens/PhotoObfuscation/Components/FaceBoxView.swift +++ b/SnapSafe/Screens/PhotoObfuscation/Components/FaceBoxView.swift @@ -12,16 +12,8 @@ import UIKit struct FaceBoxView: View { let face: DetectedFace - let originalSize: CGSize - let displaySize: CGSize var onTap: () -> Void - // Get the scaled rectangle based on the display size - private var scaledRect: CGRect { - let rect = face.scaledRect(originalSize: originalSize, displaySize: displaySize) - return rect - } - var body: some View { ZStack { // Invisible rectangle to make the entire area tappable @@ -39,27 +31,3 @@ struct FaceBoxView: View { } } } - -// Preview with a sample face -struct FaceBoxView_Previews: PreviewProvider { - static var previews: some View { - let face = DetectedFace( - rect: CGRect(x: 50, y: 50, width: 100, height: 100), - isSelected: true - ) - - return ZStack { - Color.gray - Image(systemName: "person.fill") - .resizable() - .frame(width: 200, height: 200) - - FaceBoxView( - face: face, - originalSize: CGSize(width: 400, height: 400), - displaySize: CGSize(width: 300, height: 300), - onTap: {} - ) - } - } -} diff --git a/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionControlsView.swift b/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionControlsView.swift index 4f91a98..0ce9ef9 100644 --- a/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionControlsView.swift +++ b/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionControlsView.swift @@ -4,91 +4,3 @@ // // Created by Bill Booth on 5/20/25. // - -import SwiftUI - -struct FaceDetectionControlsView: View { - var onCancel: () -> Void - var onAddBox: () -> Void - var onMask: () -> Void - var isAddingBox: Bool - var hasFacesSelected: Bool - var faceCount: Int - var selectedCount: Int - - var body: some View { - VStack(spacing: 8) { - HStack { - Button(action: onCancel) { - Label("Cancel", systemImage: "xmark") - .foregroundColor(.white) - .padding(10) - .background(Color.gray) - .cornerRadius(8) - } - - Spacer() - - Button(action: onAddBox) { - Label("Add Box", systemImage: "plus.rectangle") - .foregroundColor(.white) - .padding(10) - .background(isAddingBox ? Color.green : Color.blue) - .cornerRadius(8) - } - - Spacer() - - Button(action: onMask) { - Label("Mask Faces", systemImage: "eye.slash") - .foregroundColor(.white) - .padding(10) - .background(hasFacesSelected ? Color.blue : Color.gray) - .cornerRadius(8) - } - .disabled(!hasFacesSelected) - } - .padding(.horizontal) - - if isAddingBox { - Text("Tap anywhere on the image to add a custom box") - .font(.caption) - .foregroundColor(.green) - .padding(.horizontal) - } else { - Text("Tap faces to select them for masking. Pinch to resize boxes.") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - } - - if faceCount == 0 { - Text("No faces detected") - .font(.callout) - .foregroundColor(.secondary) - } else { - Text("\(faceCount) faces detected, \(selectedCount) selected") - .font(.callout) - .foregroundColor(.secondary) - } - } - .padding(.bottom, 10) - } -} - -struct FaceDetectionControlsView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.gray - FaceDetectionControlsView( - onCancel: {}, - onAddBox: {}, - onMask: {}, - isAddingBox: false, - hasFacesSelected: true, - faceCount: 3, - selectedCount: 1 - ) - } - } -} \ No newline at end of file diff --git a/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionOverlay.swift b/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionOverlay.swift index 3d754d5..644437a 100644 --- a/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionOverlay.swift +++ b/SnapSafe/Screens/PhotoObfuscation/Components/FaceDetectionOverlay.swift @@ -9,48 +9,36 @@ import SwiftUI import Foundation import UIKit -public struct FaceDetectionOverlay: View { - public let faces: [DetectedFace] - public let originalSize: CGSize - public let displaySize: CGSize - public let isAddingBox: Bool - - public var onTap: (UUID) -> Void - public var onCreateBox: (CGPoint) -> Void - public var onMove: (UUID, CGSize) -> Void // image-space delta - public var onSetPosition: (UUID, CGRect) -> Void // absolute position in image space - public var onResize: (UUID, CGFloat) -> Void // scale factor - public var onSetSize: (UUID, CGRect) -> Void // absolute size for smooth resizing +struct FaceDetectionOverlay: View { + let faces: [DetectedFace] + let originalSize: CGSize + let displaySize: CGSize + + var onTap: (UUID) -> Void + var onSetPosition: (UUID, CGRect) -> Void // absolute position in image space + var onSetSize: (UUID, CGRect) -> Void // absolute size for smooth resizing @State private var resizingId: UUID? @State private var dragStartPositions: [UUID: CGRect] = [:] @State private var resizeStartBounds: [UUID: CGRect] = [:] - public init( + init( faces: [DetectedFace], originalSize: CGSize, displaySize: CGSize, - isAddingBox: Bool, onTap: @escaping (UUID) -> Void, - onCreateBox: @escaping (CGPoint) -> Void, - onMove: @escaping (UUID, CGSize) -> Void, onSetPosition: @escaping (UUID, CGRect) -> Void, - onResize: @escaping (UUID, CGFloat) -> Void, onSetSize: @escaping (UUID, CGRect) -> Void ) { self.faces = faces self.originalSize = originalSize self.displaySize = displaySize - self.isAddingBox = isAddingBox self.onTap = onTap - self.onCreateBox = onCreateBox - self.onMove = onMove self.onSetPosition = onSetPosition - self.onResize = onResize self.onSetSize = onSetSize } - public var body: some View { + var body: some View { ZStack { ForEach(faces) { face in @@ -58,8 +46,6 @@ public struct FaceDetectionOverlay: View { FaceBoxView( face: face, - originalSize: originalSize, - displaySize: displaySize, onTap: { onTap(face.id) } ) .frame(width: rect.width, height: rect.height) diff --git a/SnapSafe/Screens/PhotoObfuscation/FaceDetector.swift b/SnapSafe/Screens/PhotoObfuscation/FaceDetector.swift index 8892f27..024edd4 100644 --- a/SnapSafe/Screens/PhotoObfuscation/FaceDetector.swift +++ b/SnapSafe/Screens/PhotoObfuscation/FaceDetector.swift @@ -268,10 +268,4 @@ class FaceDetector { return UIGraphicsGetImageFromCurrentImageContext() } - - // Pixelate faces with default pixelate mode - func pixelateFaces(in image: UIImage, faces: [DetectedFace]) -> UIImage? { - return maskFaces(in: image, faces: faces, modes: [.pixelate]) - } - } diff --git a/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationView.swift b/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationView.swift index 8218e92..d43c084 100644 --- a/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationView.swift +++ b/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationView.swift @@ -30,23 +30,38 @@ struct PhotoObfuscationView: View { Color.black .ignoresSafeArea() - if viewModel.isImageLoading { - ProgressView("Loading image...") - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .foregroundColor(.white) - } else { - imageContent + VStack(spacing: 0) { + // The title gets its own full-width row so it isn't truncated + // between the Cancel and Save buttons in the nav bar. + Text("Photo Obfuscation") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.black) + + if viewModel.isImageLoading { + ProgressView("Loading image...") + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + imageContent + } } } - .navigationTitle("Photo Obfuscation") + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + // We supply our own leading Cancel button, so hide the system back + // button to avoid showing two back buttons. + .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { viewModel.cancel() onDismiss() } - .foregroundColor(.white) + .foregroundStyle(.white) } ToolbarItem(placement: .navigationBarTrailing) { @@ -54,7 +69,7 @@ struct PhotoObfuscationView: View { viewModel.saveChanges() onDismiss() } - .foregroundColor(.blue) + .foregroundStyle(.blue) .fontWeight(.semibold) } } @@ -113,12 +128,8 @@ struct PhotoObfuscationView: View { faces: viewModel.detectedFaces, originalSize: viewModel.currentImage?.size ?? .zero, displaySize: viewModel.imageFrameSize, - isAddingBox: viewModel.isAddingBox, onTap: { id in viewModel.toggleFaceSelection(id: id) }, - onCreateBox: { pt in viewModel.createBox(at: pt) }, - onMove: { id, delta in viewModel.moveFace(id: id, by: delta) }, onSetPosition: { id, bounds in viewModel.setFacePosition(id: id, to: bounds) }, - onResize: { id, scale in viewModel.resizeFace(id: id, scale: scale) }, onSetSize: { id, bounds in viewModel.setFaceSize(id: id, to: bounds) } ) .frame(width: viewModel.imageFrameSize.width, height: viewModel.imageFrameSize.height) @@ -138,7 +149,7 @@ struct PhotoObfuscationView: View { .scaleEffect(1.5) Text("Processing faces...") - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.top) } .position(x: availableSize.width / 2, y: availableSize.height / 2) @@ -207,275 +218,85 @@ private struct ObfuscationControlsView: View { var isProcessing: Bool var body: some View { - VStack(spacing: 0) { - // Separator line - Divider() - .background(Color.gray.opacity(0.3)) - - HStack { - if hasManualBoxesSelected && !isAddingBox && !isFaceDetectionActive { - // Cancel manual boxes button - Button(action: { - onCancelAddBox?() - }) { - VStack(spacing: 4) { - Image(systemName: "xmark.circle") - .font(.system(size: 22)) - .frame(height: 22) - Text("Cancel") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.gray) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Obscure areas button - Button(action: { - onObscureAreas?() - }) { - VStack(spacing: 4) { - if isProcessing { - ProgressView() - .scaleEffect(0.7) - .frame(height: 22) - } else { - Image(systemName: "square.dashed") - .font(.system(size: 22)) - .frame(height: 22) - } - Text(manualBoxButtonLabel) - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Share button - Button(action: onShare) { - VStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 22)) - .frame(height: 22) - Text("Share") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - } else if isFaceDetectionActive { - // Cancel detection button - Button(action: { - onCancelDetection?() - }) { - VStack(spacing: 4) { - Image(systemName: "xmark.circle") - .font(.system(size: 22)) - .frame(height: 22) - Text("Cancel") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.gray) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Mask faces button (conditional) - if hasFacesSelected { - Button(action: { - onMaskFaces?() - }) { - VStack(spacing: 4) { - if isProcessing { - ProgressView() - .scaleEffect(0.7) - .frame(height: 22) - } else { - Image(systemName: "face.dashed.fill") - .font(.system(size: 22)) - .frame(height: 22) - } - Text(maskButtonLabel) - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - } + HStack(spacing: 0) { + if hasManualBoxesSelected && !isAddingBox && !isFaceDetectionActive { + MediaToolbarButton(icon: "xmark.circle", label: "Cancel", tint: .gray, + action: { onCancelAddBox?() }) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) - // Share button - Button(action: onShare) { - VStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 22)) - .frame(height: 22) - Text("Share") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - } else if isAddingBox { - // Cancel add box button - Button(action: { - onCancelAddBox?() - }) { - VStack(spacing: 4) { - Image(systemName: "xmark.circle") - .font(.system(size: 22)) - .frame(height: 22) - Text("Cancel") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.gray) - .frame(maxWidth: .infinity) - .frame(height: 60) - } + obfuscateButton(icon: "square.dashed", label: manualBoxButtonLabel, + action: { onObscureAreas?() }) - // Add Box button - always show when in adding box mode - Button(action: onAddBox) { - VStack(spacing: 4) { - Image(systemName: "plus.app") - .font(.system(size: 22)) - .frame(height: 22) - Text("Add Box") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.green) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Obscure areas button (conditional - only when manual boxes are selected) - if hasManualBoxesSelected { - Button(action: { - onObscureAreas?() - }) { - VStack(spacing: 4) { - if isProcessing { - ProgressView() - .scaleEffect(0.7) - .frame(height: 22) - } else { - Image(systemName: "square.dashed") - .font(.system(size: 22)) - .frame(height: 22) - } - Text(manualBoxButtonLabel) - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - } + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", tint: .blue, + action: onShare) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) - // Share button - Button(action: onShare) { - VStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 22)) - .frame(height: 22) - Text("Share") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - } else { - // Detect faces button - Button(action: onDetectFaces) { - VStack(spacing: 4) { - Image(systemName: "face.dashed") - .font(.system(size: 22)) - .frame(height: 22) - Text("Detect Faces") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.orange) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Add Box button - Button(action: onAddBox) { - VStack(spacing: 4) { - Image(systemName: "plus.app") - .font(.system(size: 22)) - .frame(height: 22) - Text("Add Box") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.green) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) - - // Share button - Button(action: onShare) { - VStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 22)) - .frame(height: 22) - Text("Share") - .font(.caption2) - .multilineTextAlignment(.center) - } - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .frame(height: 60) - } - .disabled(isProcessing) - .opacity(isProcessing ? 0.6 : 1.0) + } else if isFaceDetectionActive { + MediaToolbarButton(icon: "xmark.circle", label: "Cancel", tint: .gray, + action: { onCancelDetection?() }) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + if hasFacesSelected { + obfuscateButton(icon: "face.dashed.fill", label: maskButtonLabel, + action: { onMaskFaces?() }) } + + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", tint: .blue, + action: onShare) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + } else if isAddingBox { + MediaToolbarButton(icon: "xmark.circle", label: "Cancel", tint: .gray, + action: { onCancelAddBox?() }) + + MediaToolbarButton(icon: "plus.app", label: "Add Box", tint: .green, + action: onAddBox) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + if hasManualBoxesSelected { + obfuscateButton(icon: "square.dashed", label: manualBoxButtonLabel, + action: { onObscureAreas?() }) + } + + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", tint: .blue, + action: onShare) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + } else { + MediaToolbarButton(icon: "face.dashed", label: "Detect Faces", tint: .orange, + action: onDetectFaces) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + MediaToolbarButton(icon: "plus.app", label: "Add Box", tint: .green, + action: onAddBox) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) + + MediaToolbarButton(icon: "square.and.arrow.up", label: "Share", tint: .blue, + action: onShare) + .disabled(isProcessing).opacity(isProcessing ? 0.6 : 1.0) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(UIColor.systemBackground)) } + .glassToolbarBackground() + .padding(.horizontal, 24) + .padding(.bottom, 20) .animation(.easeInOut(duration: 0.2), value: isFaceDetectionActive) .animation(.easeInOut(duration: 0.2), value: hasFacesSelected) .animation(.easeInOut(duration: 0.2), value: isProcessing) } + + /// Destructive obfuscate button — shows a spinner while processing. + @ViewBuilder + private func obfuscateButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + if isProcessing { + MediaToolbarButton(icon: nil, label: label, tint: .red, action: action) { + ProgressView().controlSize(.small) + } + .disabled(true) + } else { + MediaToolbarButton(icon: icon, label: label, tint: .red, action: action) + .opacity(isProcessing ? 0.6 : 1.0) + } + } } diff --git a/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationViewModel.swift b/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationViewModel.swift index 5663ed9..aff7e84 100644 --- a/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationViewModel.swift +++ b/SnapSafe/Screens/PhotoObfuscation/PhotoObfuscationViewModel.swift @@ -112,7 +112,8 @@ final class PhotoObfuscationViewModel: ObservableObject { isImageLoading = true do { - let image = try await secureImageRepository.readImage(photoDef) + let data = try await secureImageRepository.readImage(photoDef) + guard let image = UIImage(data: data) else { throw ImageRepositoryError.invalidImageData } await MainActor.run { self.currentImage = image self.isImageLoading = false @@ -161,14 +162,6 @@ final class PhotoObfuscationViewModel: ObservableObject { } } - func toggleFaceSelection(_ index: Int) { - guard index >= 0 && index < detectedFaces.count else { - Logger.storage.error("ERROR: Invalid face index: \(index), valid range: 0..<\(detectedFaces.count)") - return - } - detectedFaces[index].isSelected.toggle() - } - func applyFaceObscuring() { guard let imageToProcess = currentImage else { return } @@ -416,27 +409,6 @@ extension PhotoObfuscationViewModel { detectedFaces[idx].isSelected.toggle() } - // Create a new box (larger size for easy finger resizing) centered at the tapped image point - func createBox(at displayPoint: CGPoint) { - guard let img = currentImage else { return } - let imagePoint = DetectedFace.imagePoint(fromDisplay: displayPoint, - originalSize: img.size, - displaySize: imageFrameSize) - let size: CGFloat = 900 - let rect = CGRect(x: imagePoint.x - size/2, y: imagePoint.y - size/2, width: size, height: size) - let clamped = clamp(rect, in: img.size) - detectedFaces.append(DetectedFace(bounds: clamped, isSelected: true, isUserCreated: true)) - } - - // Drag move in image-space delta - func moveFace(id: UUID, by deltaImage: CGSize) { - guard let img = currentImage, let idx = detectedFaces.firstIndex(where: { $0.id == id }) else { return } - var r = detectedFaces[idx].bounds - r.origin.x += deltaImage.width - r.origin.y += deltaImage.height - detectedFaces[idx].bounds = clamp(r, in: img.size) - } - // Set absolute position for smooth dragging func setFacePosition(id: UUID, to newBounds: CGRect) { guard let img = currentImage, let idx = detectedFaces.firstIndex(where: { $0.id == id }) else { return } @@ -449,26 +421,6 @@ extension PhotoObfuscationViewModel { detectedFaces[idx].bounds = clamp(newBounds, in: img.size) } - - // Pinch resize around the face center; `scale` is the gesture's instantaneous factor - func resizeFace(id: UUID, scale: CGFloat) { - guard let img = currentImage, let idx = detectedFaces.firstIndex(where: { $0.id == id }) else { return } - let r = detectedFaces[idx].bounds - let center = CGPoint(x: r.midX, y: r.midY) - - var newW = max(12, r.width * scale) - var newH = max(12, r.height * scale) - - // Convert back to a rect centered at original center - var newRect = CGRect(x: center.x - newW/2, y: center.y - newH/2, width: newW, height: newH) - newRect = clamp(newRect, in: img.size) - - // If clamped shrank asymmetrically, keep min size - newW = max(12, newRect.width) - newH = max(12, newRect.height) - detectedFaces[idx].bounds = CGRect(x: newRect.origin.x, y: newRect.origin.y, width: newW, height: newH) - } - // MARK: - Helpers private func clamp(_ rect: CGRect, in imageSize: CGSize) -> CGRect { diff --git a/SnapSafe/Screens/PinSetup/IntroductionSlideView.swift b/SnapSafe/Screens/PinSetup/IntroductionSlideView.swift index 2cc0304..b294bcb 100644 --- a/SnapSafe/Screens/PinSetup/IntroductionSlideView.swift +++ b/SnapSafe/Screens/PinSetup/IntroductionSlideView.swift @@ -21,7 +21,7 @@ struct IntroductionSlideView: View { // Icon Image(systemName: slide.icon) .font(.system(size: 80, weight: .light)) - .foregroundColor(slide.iconColor) + .foregroundStyle(slide.iconColor) .padding(.top, 20) // Title @@ -35,7 +35,7 @@ struct IntroductionSlideView: View { Text(slide.description) .font(.body) .multilineTextAlignment(.center) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineSpacing(4) .padding(.horizontal, 30) diff --git a/SnapSafe/Screens/PinSetup/PINSetupIntroView.swift b/SnapSafe/Screens/PinSetup/PINSetupIntroView.swift index 50cc2fa..83fd08d 100644 --- a/SnapSafe/Screens/PinSetup/PINSetupIntroView.swift +++ b/SnapSafe/Screens/PinSetup/PINSetupIntroView.swift @@ -67,11 +67,11 @@ struct PINSetupIntroView: View { }) { Text("Skip") .fontWeight(.medium) - .foregroundColor(.blue) + .foregroundStyle(.blue) .frame(maxWidth: .infinity) .frame(height: 50) .background(Color.blue.opacity(0.1)) - .cornerRadius(12) + .clipShape(.rect(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.blue, lineWidth: 1) @@ -88,13 +88,13 @@ struct PINSetupIntroView: View { Text("Continue") .fontWeight(.medium) Image(systemName: "arrow.right") - .font(.system(size: 14, weight: .medium)) + .font(.subheadline) } - .foregroundColor(.white) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background(Color.blue) - .cornerRadius(12) + .clipShape(.rect(cornerRadius: 12)) } } } else { @@ -108,13 +108,13 @@ struct PINSetupIntroView: View { Text(isLastIntroSlide ? "Set Up PIN" : "Continue") .fontWeight(.medium) Image(systemName: "arrow.right") - .font(.system(size: 14, weight: .medium)) + .font(.subheadline) } - .foregroundColor(.white) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background(Color.blue) - .cornerRadius(12) + .clipShape(.rect(cornerRadius: 12)) } } } diff --git a/SnapSafe/Screens/PinSetup/PINSetupView.swift b/SnapSafe/Screens/PinSetup/PINSetupView.swift index 56b7484..682823c 100644 --- a/SnapSafe/Screens/PinSetup/PINSetupView.swift +++ b/SnapSafe/Screens/PinSetup/PINSetupView.swift @@ -12,108 +12,200 @@ import Logging struct PINSetupView: View { @StateObject private var viewModel = PINSetupViewModel() @Environment(\.scenePhase) private var scenePhase - + @State private var revealPin = false + @State private var revealConfirmPin = false + @State private var showAlphanumericHelp = false + // Cache computed values to reduce view updates private var buttonDisabled: Bool { !viewModel.canSubmit } - + private var buttonBackgroundColor: Color { viewModel.canSubmit ? Color.blue : Color.gray } - + + // Reveal the action once the user has started entering a PIN (or while the + // PIN is being set) — avoids an idle, disabled button at rest. + private var showSetPinButton: Bool { + !viewModel.pin.isEmpty || !viewModel.confirmPin.isEmpty || viewModel.isLoading + } + + private var setPinButton: some View { + Button { + Task { + let success = await viewModel.createPin() + if success { + Logger.ui.info("PIN setup complete, marking intro as completed") + } + } + } label: { + HStack { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + .foregroundStyle(.white) + } + Text(viewModel.isLoading ? "Setting PIN..." : "Set PIN") + .foregroundStyle(.white) + } + .padding() + .frame(minWidth: 200, maxWidth: 300) + .background(buttonBackgroundColor) + .clipShape(.rect(cornerRadius: 10)) + } + .disabled(buttonDisabled) + } + var body: some View { - NavigationView { - ScrollView { + ScrollView { VStack(spacing: 30) { Image(systemName: "lock.shield") .font(.system(size: 70)) - .foregroundColor(.blue) + .foregroundStyle(.blue) .padding(.top, 50) - + .accessibilityHidden(true) + Text("Set Up Security PIN") .font(.largeTitle) .bold() - + Text("Please create a PIN to secure your photos") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - + VStack(spacing: 20) { - SecureField("Enter PIN", text: $viewModel.pin) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .multilineTextAlignment(.center) - .padding() - .background(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1)) - .padding(.horizontal, min(50, UIScreen.main.bounds.width * 0.1)) - - SecureField("Confirm PIN", text: $viewModel.confirmPin) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .multilineTextAlignment(.center) - .padding() - .background(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1)) - .padding(.horizontal, min(50, UIScreen.main.bounds.width * 0.1)) + HStack { + Button("About Alphanumeric PINs", systemImage: "questionmark.circle") { + showAlphanumericHelp = true + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + + Toggle(isOn: Binding( + get: { viewModel.isAlphanumeric }, + set: { viewModel.setAlphanumeric($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + Text("Use Alphanumeric PIN") + .font(.subheadline) + Text("Letters and numbers allowed") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .disabled(!viewModel.pin.isEmpty || !viewModel.confirmPin.isEmpty) + } + .padding(.horizontal, min(50, UIScreen.main.bounds.width * 0.1)) + + pinField( + placeholder: "Enter PIN", + text: $viewModel.pin, + reveal: $revealPin + ) + + if !viewModel.pin.isEmpty && viewModel.pin.count < 6 { + Text(PINStrings.shortPinWarning) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, min(50, UIScreen.main.bounds.width * 0.1)) + .transition(.opacity) + } + + pinField( + placeholder: "Confirm PIN", + text: $viewModel.confirmPin, + reveal: $revealConfirmPin + ) } - + .animation(.snappy, value: !viewModel.pin.isEmpty && viewModel.pin.count < 6) + if viewModel.showError { Text(viewModel.errorMessage) - .foregroundColor(.red) + .foregroundStyle(.red) .font(.callout) .padding(.top, 5) } - + HStack { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) + .foregroundStyle(.orange) Text("Choose a different PIN than the one used to unlock this device!") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.horizontal, 30) .padding(.bottom, 20) - - Button(action: { - Task { - let success = await viewModel.createPin() - if success { - Logger.ui.info("PIN setup complete, marking intro as completed") - } - } - }) { - HStack { - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - .foregroundColor(.white) - } - Text(viewModel.isLoading ? "Setting PIN..." : "Set PIN") - .foregroundColor(.white) - } - .padding() - .frame(minWidth: 200, maxWidth: 300) - .background(buttonBackgroundColor) - .cornerRadius(10) - } - .disabled(buttonDisabled) - .padding(.top, 20) - .padding(.bottom, 50) + + } + } + .safeAreaInset(edge: .bottom) { + if showSetPinButton { + setPinButton + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.bar) + .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .animation(.snappy, value: showSetPinButton) .navigationBarTitle("", displayMode: .inline) .navigationBarHidden(true) .obscuredWhenInactive() .screenCaptureProtected() + .alert("Alphanumeric PINs", isPresented: $showAlphanumericHelp) { + Button("OK", role: .cancel) {} + } message: { + Text(PINStrings.alphanumericHelpMessage) + } + .task { + await viewModel.loadAlphanumericSetting() + } .onChange(of: scenePhase) { _, newPhase in // Clear PIN content and dismiss keyboard when app goes to background or inactive if newPhase == .background || newPhase == .inactive { viewModel.clearPinContent() } } + } + + private func pinField( + placeholder: String, + text: Binding, + reveal: Binding + ) -> some View { + HStack(spacing: 0) { + // Keeps the secure dots visually centered by balancing the trailing eye button. + Image(systemName: "eye") + .opacity(0) + .padding(.leading, 12) + .accessibilityHidden(true) + + RevealableSecureField( + text: text, + placeholder: placeholder, + isAlphanumeric: viewModel.isAlphanumeric, + maxLength: maxPinLength, + isSecure: !reveal.wrappedValue, + isEnabled: true + ) + + Button { + reveal.wrappedValue.toggle() + } label: { + Image(systemName: reveal.wrappedValue ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + .padding(.trailing, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(reveal.wrappedValue ? "Hide PIN" : "Show PIN") } - .navigationViewStyle(.stack) + .background(RoundedRectangle(cornerRadius: 8).stroke(Color.gray, lineWidth: 1)) + .padding(.horizontal, min(50, UIScreen.main.bounds.width * 0.1)) } } diff --git a/SnapSafe/Screens/PinSetup/PINSetupViewModel.swift b/SnapSafe/Screens/PinSetup/PINSetupViewModel.swift index d299af2..4dd8307 100644 --- a/SnapSafe/Screens/PinSetup/PINSetupViewModel.swift +++ b/SnapSafe/Screens/PinSetup/PINSetupViewModel.swift @@ -6,15 +6,14 @@ // import Foundation -import Combine import FactoryKit @MainActor final class PINSetupViewModel: ObservableObject { - + @Injected(\.settingsDataSource) private var settings: SettingsDataSource - + // MARK: - Published Properties @Published var pin: String = "" { @@ -25,7 +24,7 @@ final class PINSetupViewModel: ObservableObject { } } } - + @Published var confirmPin: String = "" { didSet { @@ -35,114 +34,119 @@ final class PINSetupViewModel: ObservableObject { } } } - + @Published var showError: Bool = false @Published var errorMessage: String = "" @Published var isLoading: Bool = false - + + /// Global alphanumeric-PIN preference, mirrored from settings. The setup + /// screen is where this choice is made; it applies to the poison pill and + /// the unlock screen too. + @Published var isAlphanumeric: Bool = false + // MARK: - Computed Properties var isPINValid: Bool { - pin.count >= MIN_PIN_LENGTH && pin.count <= MAX_PIN_LENGTH - && confirmPin.count >= MIN_PIN_LENGTH && confirmPin.count <= MAX_PIN_LENGTH + pin.count >= minPinLength && pin.count <= maxPinLength + && confirmPin.count >= minPinLength && confirmPin.count <= maxPinLength } - + var canSubmit: Bool { isPINValid && !isLoading } - + // MARK: - Dependencies @Injected(\.createPinUseCase) private var createPinUseCase: CreatePinUseCase @Injected(\.pinStrengthCheckUseCase) private var pinStrengthCheckUseCase: PinStrengthCheckUseCase - - // MARK: - Private Properties - private var cancellables = Set() - + // MARK: - Initialization init() { setupBindings() } - + + // MARK: - Alphanumeric preference + /// Seed the toggle from the persisted global setting. + func loadAlphanumericSetting() async { + isAlphanumeric = await settings.getAlphanumericPinEnabled() + } + + /// Update and persist the global preference, re-filtering any entered text. + func setAlphanumeric(_ enabled: Bool) { + guard enabled != isAlphanumeric else { return } + isAlphanumeric = enabled + pin = validateAndFilterPIN(pin) + confirmPin = validateAndFilterPIN(confirmPin) + Task { await settings.setAlphanumericPinEnabled(enabled) } + } + // MARK: - Private Methods private func setupBindings() { } - + private func clearError() { showError = false errorMessage = "" } - + // MARK: - PIN Validation Methods - func validateAndFilterPIN(_ newValue: String, isConfirm: Bool = false) -> String { - var filtered = newValue - - // Only allow numbers - filtered = filtered.filter { $0.isNumber } - - // Limit to max digits - if filtered.count > MAX_PIN_LENGTH { - filtered = String(filtered.prefix(MAX_PIN_LENGTH)) + func validateAndFilterPIN(_ newValue: String) -> String { + // Filter to the active PIN type: letters + digits when alphanumeric, + // digits only otherwise. + var filtered = isAlphanumeric + ? newValue.filter { $0.isLetter || $0.isNumber } + : newValue.filter { $0.isNumber } + + // Limit to max length + if filtered.count > maxPinLength { + filtered = String(filtered.prefix(maxPinLength)) } - + return filtered } - - + // MARK: - Business Logic func createPin() async -> Bool { guard canSubmit else { return false } - + clearError() - + isLoading = true defer { isLoading = false } - + // Check if PINs match if pin != confirmPin { showError(message: "PINs do not match") return false } - - // Validate PIN format (already done above, but keeping for clarity) - guard pin.allSatisfy({ $0.isNumber }) else { - showError(message: "PIN must contain only numbers") - return false - } - - // Check PIN strength - if !pinStrengthCheckUseCase.isPinStrongEnough(pin) { - showError(message: "PIN is too weak. Avoid common patterns like 1234 or repeated digits.") + + // Check PIN strength against the active (global) PIN type. + if !pinStrengthCheckUseCase.isPinStrongEnough(pin, isAlphanumeric: isAlphanumeric) { + showError(message: isAlphanumeric + ? "PIN is too weak. Avoid common words and repeated characters." + : "PIN is too weak. Avoid common patterns like 1234 or repeated digits.") return false } - + // Create the PIN using the use case let success = await createPinUseCase.createPin(pin) - + if !success { showError(message: "Failed to create PIN. Please try again.") return false } - + await settings.setIntroCompleted(true) - + return true } - + // MARK: - Error Handling private func showError(message: String) { pin = "" confirmPin = "" - + errorMessage = message showError = true } - - // MARK: - Reset Methods - func reset() { - pin = "" - confirmPin = "" - clearError() - isLoading = false - } func clearPinContent() { pin = "" diff --git a/SnapSafe/Screens/PinSetup/PINStrings.swift b/SnapSafe/Screens/PinSetup/PINStrings.swift new file mode 100644 index 0000000..d469e5b --- /dev/null +++ b/SnapSafe/Screens/PinSetup/PINStrings.swift @@ -0,0 +1,22 @@ +// +// PINStrings.swift +// SnapSafe +// + +import Foundation + +enum PINStrings { + static let shortPinWarning = "Short PINs are less secure. Consider using 6+ characters." + + /// Mirrors Android's `pin_alphanumeric_help_message`. 62^4 = 14,776,336. + static let alphanumericHelpMessage: LocalizedStringResource = """ + Alphanumeric PINs allow letters (a-z, A-Z) in addition to digits (0-9). + + This dramatically increases security: + + • A 4-digit numeric PIN has 10,000 possible combinations + • A 4-character alphanumeric PIN has over 14 million combinations + + Using letters and numbers together makes your PIN much harder to guess or brute-force. + """ +} diff --git a/SnapSafe/Screens/PinSetup/RevealableSecureField.swift b/SnapSafe/Screens/PinSetup/RevealableSecureField.swift new file mode 100644 index 0000000..c850664 --- /dev/null +++ b/SnapSafe/Screens/PinSetup/RevealableSecureField.swift @@ -0,0 +1,152 @@ +// +// RevealableSecureField.swift +// SnapSafe +// + +import SwiftUI +import UIKit + +/// A PIN entry field backed by `UITextField` so we can control behavior that +/// plain SwiftUI `SecureField` doesn't expose: +/// +/// - **Single-character delete after refocus.** A secure `UITextField` purges +/// its entire contents on the first edit after it regains focus. We intercept +/// `shouldChangeCharactersIn`, apply the edit ourselves, and return `false`, +/// which bypasses that purge so backspace removes one character. +/// - **Reveal toggle.** `isSecure` flips `isSecureTextEntry` without losing the +/// typed text (UIKit otherwise drops it when secure entry changes). +/// - **Live keyboard swap.** When `isAlphanumeric` changes while the field is +/// focused, we call `reloadInputViews()` so the keyboard updates immediately. +/// +/// `isAlphanumeric` is driven by the global app preference, not a per-PIN type. +@MainActor +struct RevealableSecureField: UIViewRepresentable { + @Binding var text: String + /// Optional outbound report of first-responder state, for callers that need + /// to react to focus (e.g. hiding a header while editing). + var isFocused: Binding? = nil + let placeholder: String + let isAlphanumeric: Bool + let maxLength: Int + let isSecure: Bool + let isEnabled: Bool + + func makeUIView(context: Context) -> PaddedPlainTextField { + let field = PaddedPlainTextField() + field.isSecureTextEntry = isSecure + field.isEnabled = isEnabled + field.keyboardType = isAlphanumeric ? .default : .numberPad + field.textContentType = .oneTimeCode + field.textAlignment = .center + field.borderStyle = .none + field.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [.foregroundColor: UIColor.secondaryLabel] + ) + field.delegate = context.coordinator + field.addTarget( + context.coordinator, + action: #selector(Coordinator.editingChanged(_:)), + for: .editingChanged + ) + field.setContentHuggingPriority(.defaultLow, for: .horizontal) + return field + } + + func updateUIView(_ uiView: PaddedPlainTextField, context: Context) { + context.coordinator.maxLength = maxLength + context.coordinator.isAlphanumeric = isAlphanumeric + + if uiView.text != text { uiView.text = text } + uiView.isEnabled = isEnabled + + let desiredKeyboard: UIKeyboardType = isAlphanumeric ? .default : .numberPad + if uiView.keyboardType != desiredKeyboard { + uiView.keyboardType = desiredKeyboard + if uiView.isFirstResponder { uiView.reloadInputViews() } + } + + if uiView.isSecureTextEntry != isSecure { + // Toggling secure entry makes UIKit drop the field's text. Re-assign + // it so revealing/hiding preserves what the user typed. + let saved = uiView.text + uiView.isSecureTextEntry = isSecure + uiView.text = nil + uiView.text = saved + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, isFocused: isFocused, isAlphanumeric: isAlphanumeric, maxLength: maxLength) + } + + @MainActor + final class Coordinator: NSObject, UITextFieldDelegate { + @Binding var text: String + let isFocused: Binding? + var isAlphanumeric: Bool + var maxLength: Int + + init(text: Binding, isFocused: Binding?, isAlphanumeric: Bool, maxLength: Int) { + self._text = text + self.isFocused = isFocused + self.isAlphanumeric = isAlphanumeric + self.maxLength = maxLength + } + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + let current = textField.text ?? "" + guard let swiftRange = Range(range, in: current) else { return false } + let proposed = current.replacingCharacters(in: swiftRange, with: string) + let updated = String(filtered(proposed).prefix(maxLength)) + + // Apply the edit ourselves and return false. This bypasses the secure + // field's "purge all text on first edit after refocus" behavior, so a + // backspace removes a single character. + textField.text = updated + moveCaretToEnd(textField) + if text != updated { text = updated } + return false + } + + @objc func editingChanged(_ sender: UITextField) { + // Safety net for input paths that bypass shouldChangeCharactersIn + // (autofill, dictation): keep the binding in sync and enforce the filter. + let updated = String(filtered(sender.text ?? "").prefix(maxLength)) + if sender.text != updated { sender.text = updated } + if text != updated { text = updated } + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + if isFocused?.wrappedValue == false { isFocused?.wrappedValue = true } + } + + func textFieldDidEndEditing(_ textField: UITextField) { + if isFocused?.wrappedValue == true { isFocused?.wrappedValue = false } + } + + private func filtered(_ value: String) -> String { + if isAlphanumeric { + return String(value.filter { $0.isLetter || $0.isNumber }) + } else { + return String(value.filter(\.isNumber)) + } + } + + private func moveCaretToEnd(_ textField: UITextField) { + let end = textField.endOfDocument + textField.selectedTextRange = textField.textRange(from: end, to: end) + } + } +} + +final class PaddedPlainTextField: UITextField { + private let inset = UIEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) + override func textRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } + override func editingRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } +} diff --git a/SnapSafe/Screens/PinVerification/PINEntryField.swift b/SnapSafe/Screens/PinVerification/PINEntryField.swift new file mode 100644 index 0000000..7e9edc4 --- /dev/null +++ b/SnapSafe/Screens/PinVerification/PINEntryField.swift @@ -0,0 +1,141 @@ +// +// PINEntryField.swift +// SnapSafe +// + +import SwiftUI +import UIKit + +@MainActor +struct PINEntryField: UIViewRepresentable { + @Binding var text: String + let maxLength: Int + let isEnabled: Bool + let shouldFocus: Bool + let isAlphanumeric: Bool + + func makeUIView(context: Context) -> PaddedSecureTextField { + let field = PaddedSecureTextField() + field.isSecureTextEntry = true + field.keyboardType = isAlphanumeric ? .default : .numberPad + field.textContentType = .oneTimeCode + field.textAlignment = .center + field.borderStyle = .none + field.attributedPlaceholder = NSAttributedString( + string: "PIN", + attributes: [.foregroundColor: UIColor.secondaryLabel] + ) + field.layer.cornerRadius = 8 + field.layer.borderWidth = 1 + field.layer.borderColor = UIColor.systemGray3.cgColor + field.delegate = context.coordinator + field.addTarget( + context.coordinator, + action: #selector(Coordinator.editingChanged(_:)), + for: .editingChanged + ) + field.setContentHuggingPriority(.defaultLow, for: .horizontal) + return field + } + + func updateUIView(_ uiView: PaddedSecureTextField, context: Context) { + if uiView.text != text { uiView.text = text } + uiView.isEnabled = isEnabled + uiView.keyboardType = isAlphanumeric ? .default : .numberPad + context.coordinator.maxLength = maxLength + context.coordinator.isAlphanumeric = isAlphanumeric + + // Hand the desired focus state to the field. The field itself owns the + // *when* — it (re)attempts first responder on window attachment and + // retries until it succeeds — so we no longer race the overlay/scene + // transition from here. See `PaddedSecureTextField.wantsFocus`. + uiView.wantsFocus = shouldFocus && isEnabled + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, maxLength: maxLength, isAlphanumeric: isAlphanumeric) + } + + @MainActor + final class Coordinator: NSObject, UITextFieldDelegate { + @Binding var text: String + var maxLength: Int + var isAlphanumeric: Bool + + init(text: Binding, maxLength: Int, isAlphanumeric: Bool) { + self._text = text + self.maxLength = maxLength + self.isAlphanumeric = isAlphanumeric + } + + @objc func editingChanged(_ sender: UITextField) { + let raw = sender.text ?? "" + let filtered = isAlphanumeric + ? String(raw.filter { $0.isLetter || $0.isNumber }.prefix(maxLength)) + : String(raw.filter(\.isNumber).prefix(maxLength)) + if filtered != raw { sender.text = filtered } + if text != filtered { text = filtered } + } + } +} + +final class PaddedSecureTextField: UITextField { + private let inset = UIEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) + override func textRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } + override func editingRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { bounds.inset(by: inset) } + + // MARK: - Reliable auto-focus + + /// Upper bound on retry attempts so a genuinely un-focusable field (e.g. + /// disabled, or never key) can't spin forever. + private static let maxFocusAttempts = 20 + /// Spacing between retries. 20 × 0.05s = 1s ceiling, which comfortably spans + /// the 0.15s overlay transition and any foreground settling. + private static let focusRetryInterval: TimeInterval = 0.05 + + private var focusAttemptsRemaining = 0 + + /// Desired focus state, set by `PINEntryField.updateUIView`. Setting `true` + /// makes the field keep attempting to become first responder (across runloop + /// turns) until it succeeds or focus is no longer wanted. Setting `false` + /// resigns. Idempotent — assigning the same value is a no-op. + var wantsFocus = false { + didSet { + guard wantsFocus != oldValue else { return } + if wantsFocus { + armFocus() + } else if isFirstResponder { + resignFirstResponder() + } + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + // Entering the window is the exact moment first-responder eligibility + // changes — the precise point the old single-shot attempt used to miss. + // Re-arm from a clean budget now that a window is available. + if wantsFocus, window != nil { + armFocus() + } + } + + private func armFocus() { + focusAttemptsRemaining = Self.maxFocusAttempts + attemptFocus() + } + + private func attemptFocus() { + guard wantsFocus, isEnabled, window != nil, !isFirstResponder else { return } + if becomeFirstResponder() { return } + + // Not ready yet (window not key / responder transition in flight). + // Retry on a later runloop turn until the budget is exhausted. + guard focusAttemptsRemaining > 0 else { return } + focusAttemptsRemaining -= 1 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.focusRetryInterval) { [weak self] in + self?.attemptFocus() + } + } +} diff --git a/SnapSafe/Screens/PinVerification/PINVerificationView.swift b/SnapSafe/Screens/PinVerification/PINVerificationView.swift index ecee136..9ad028c 100644 --- a/SnapSafe/Screens/PinVerification/PINVerificationView.swift +++ b/SnapSafe/Screens/PinVerification/PINVerificationView.swift @@ -9,119 +9,120 @@ import SwiftUI struct PINVerificationView: View { @StateObject private var viewModel = PINVerificationViewModel() - @FocusState private var isPINFieldFocused: Bool @Environment(\.scenePhase) private var scenePhase - - - var body: some View { - VStack(spacing: 30) { - Image(systemName: "lock.shield") - .font(.system(size: 70)) - .foregroundColor(.blue) - .padding(.top, 50) - - Text("SnapSafe") - .foregroundColor(.primary) - .font(.largeTitle) - .bold() - Text("Enter your PIN to continue") - .foregroundColor(.secondary) - - if viewModel.shouldShowAttemptsWarning { - Text(viewModel.attemptsWarningMessage) - .foregroundColor(.red) - .font(.callout) - .padding(.top, 5) + private var showUnlockButton: Bool { + !viewModel.pin.isEmpty || viewModel.isLoading + } + + private var shouldFocusField: Bool { + scenePhase == .active && !viewModel.isLoading + } + + private var unlockButton: some View { + Button(action: viewModel.unlockButtonTapped) { + HStack { + if viewModel.isLastAttempt { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.white) + } + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + .foregroundStyle(.white) + } + Text(viewModel.unlockButtonText) + .foregroundStyle(.white) } - - SecureField("PIN", text: $viewModel.pin, prompt: Text("PIN").foregroundColor(.secondary)) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .multilineTextAlignment(.center) - .padding() - .foregroundColor(.primary) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(UIColor.systemGray3), lineWidth: 1) + .padding() + .frame(width: 200) + .background(viewModel.unlockButtonBackgroundColor) + .clipShape(.rect(cornerRadius: 10)) + } + .disabled(viewModel.isUnlockButtonDisabled) + .accessibilityLabel(viewModel.unlockButtonText) + .accessibilityHint(viewModel.isLastAttempt ? "Warning: one attempt remaining before data wipe" : "") + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Text("SnapSafe") + .foregroundStyle(.primary) + .font(.largeTitle) + .bold() + .padding(.top, 32) + + Text("Enter your PIN to continue") + .foregroundStyle(.secondary) + + if viewModel.shouldShowAttemptsWarning { + Text(viewModel.attemptsWarningMessage) + .foregroundStyle(.red) + .font(.callout) + } + + PINEntryField( + text: $viewModel.pin, + maxLength: maxPinLength, + isEnabled: !viewModel.isLoading, + shouldFocus: shouldFocusField, + isAlphanumeric: viewModel.isAlphanumeric ) + .frame(height: 52) .padding(.horizontal, 50) - .focused($isPINFieldFocused) - .disabled(viewModel.isLoading) .onChange(of: viewModel.pin) { _, newValue in viewModel.updatePIN(newValue) } - .onChange(of: viewModel.isLoading) { _, isLoading in - if isLoading { - isPINFieldFocused = false - } + + if viewModel.showError { + Text(viewModel.errorMessage) + .foregroundStyle(.red) + .font(.callout) } - - if viewModel.showError { - Text(viewModel.errorMessage) - .foregroundColor(.red) - .font(.callout) - .padding(.top, 5) - } - - Button(action: { - isPINFieldFocused = false - viewModel.unlockButtonTapped() - }) { - HStack { - if viewModel.isLastAttempt { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.white) - } - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - .foregroundColor(.white) - } - Text(viewModel.unlockButtonText) - .foregroundColor(.white) + + if viewModel.showRetryableError { + Text(viewModel.retryableErrorMessage) + .foregroundStyle(.orange) + .font(.callout) + } + + if viewModel.shouldShowAttemptsWarning { + Text("10 failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST!") + .foregroundStyle(.red) + .font(.callout) + .accessibilityLabel( + "Warning: 10 failed attempts will result in a full data wipe. All photos will be lost." + ) } - .padding() - .frame(width: 200) - .background(viewModel.unlockButtonBackgroundColor) - .cornerRadius(10) } - .disabled(viewModel.isUnlockButtonDisabled) - .padding(.top, 20) - - if viewModel.shouldShowAttemptsWarning { - Text("10 failed attempts will result in a full data wipe.\nALL PHOTOS WILL BE LOST!") - .foregroundColor(.red) - .font(.callout) - .padding(.top, 5) + .frame(maxWidth: .infinity) + } + .safeAreaInset(edge: .bottom) { + if showUnlockButton { + unlockButton + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.bar) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - - Spacer() } - .onAppear { + .animation(.snappy, value: showUnlockButton) + .task { viewModel.onAppear() - isPINFieldFocused = true } .onDisappear { viewModel.onDisappear() } .onChange(of: scenePhase) { _, newPhase in - // Clear PIN content and dismiss keyboard when app goes to background or inactive - if newPhase == .background || newPhase == .inactive { - isPINFieldFocused = false + if newPhase != .active { viewModel.clearPinContent() } } .obscuredWhenInactive() .screenCaptureProtected() - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - isPINFieldFocused = false - } - } - } + .sensoryFeedback(.impact(weight: .light), trigger: viewModel.pin) + .sensoryFeedback(.error, trigger: viewModel.showError) { _, new in new } } } diff --git a/SnapSafe/Screens/PinVerification/PINVerificationViewModel.swift b/SnapSafe/Screens/PinVerification/PINVerificationViewModel.swift index 9f46787..e656db9 100644 --- a/SnapSafe/Screens/PinVerification/PINVerificationViewModel.swift +++ b/SnapSafe/Screens/PinVerification/PINVerificationViewModel.swift @@ -13,47 +13,51 @@ import Logging @MainActor final class PINVerificationViewModel: ObservableObject { // MARK: - Published Properties - + @Published var pin = "" @Published var showError = false + @Published var showRetryableError = false @Published var isLoading = false @Published var backoffSeconds = 0 @Published var failedAttempts = 0 - + @Published var isAlphanumeric: Bool = false + // MARK: - Timer private var backoffTimer: Timer? - + // MARK: - Dependencies - + @Injected(\.authorizationRepository) private var authorizationRepository: AuthorizationRepository - + @Injected(\.verifyPinUseCase) private var verifyPinUseCase: VerifyPinUseCase - + @Injected(\.securityResetUseCase) private var securityResetUseCase: SecurityResetUseCase - - + + @Injected(\.settingsDataSource) + private var settings: SettingsDataSource + // MARK: - Computed Properties - + var isUnlockButtonDisabled: Bool { - pin.count < MIN_PIN_LENGTH || isLoading || backoffSeconds > 0 + pin.count < minPinLength || isLoading || backoffSeconds > 0 } - + var unlockButtonBackgroundColor: Color { - if pin.count >= MIN_PIN_LENGTH && !isLoading && backoffSeconds == 0 { + if pin.count >= minPinLength && !isLoading && backoffSeconds == 0 { // Red for final attempt, blue for normal attempts return isLastAttempt ? Color.red : Color.blue } else { return Color.gray } } - + var isLastAttempt: Bool { failedAttempts >= (AuthorizationRepository.MAX_FAILED_ATTEMPTS - 1) } - + var unlockButtonText: String { if backoffSeconds > 0 { return "Wait (\(backoffSeconds)s)" @@ -63,88 +67,113 @@ final class PINVerificationViewModel: ObservableObject { return "Unlock" } } - + var errorMessage: String { "Invalid PIN. Please try again." } - + + var retryableErrorMessage: String { + "Something went wrong unlocking. Please try again." + } + var shouldShowAttemptsWarning: Bool { failedAttempts > 2 } - + var attemptsWarningMessage: String { let remaining = AuthorizationRepository.MAX_FAILED_ATTEMPTS - failedAttempts return "Attempts remaining \(remaining)/\(AuthorizationRepository.MAX_FAILED_ATTEMPTS)" } - + // MARK: - Public Methods - + func onAppear() { // Update last active time when view appears authorizationRepository.keepAliveSession() - + Task { await updateBackoffTime() await updateCurrentFailedAttempts() + await loadAlphanumericSetting() } } - + func onDisappear() { stopBackoffTimer() } - + func updatePIN(_ newValue: String) { - // Limit to 10 digits + // Limit to max length var filteredValue = newValue - if filteredValue.count > MAX_PIN_LENGTH { - filteredValue = String(filteredValue.prefix(MAX_PIN_LENGTH)) - } - - // Only allow numbers - if !filteredValue.allSatisfy({ $0.isNumber }) { - filteredValue = filteredValue.filter { $0.isNumber } + if filteredValue.count > maxPinLength { + filteredValue = String(filteredValue.prefix(maxPinLength)) } - + + // Filter characters based on the global PIN type. + filteredValue = isAlphanumeric + ? filteredValue.filter { $0.isLetter || $0.isNumber } + : filteredValue.filter { $0.isNumber } + pin = filteredValue } - + func verifyPIN() async { isLoading = true showError = false - - let success = await verifyPinUseCase.verifyPin(pin) - + showRetryableError = false + + let result = await verifyPinUseCase.verifyPin(pin) + isLoading = false - - if success { - // PIN verification successful (includes poison pill handling) + + switch result { + case .success: + // PIN verification successful (includes poison pill handling). + // The repository already reset the counter to 0 on success + // (AuthorizePinUseCase.resetFailedAttempts); just reflect that + // locally rather than writing it a second time (M1). Logger.security.info("PIN verification successful") - - // Reset failed attempts counter on successful verification - await setCurrentFailedAttempts(0) - + + failedAttempts = 0 + // Update UI state showError = false - + showRetryableError = false + // Clear the PIN field for next time pin = "" - } else { - // PIN verification failed + + case .failure(let error): + // Transient / retryable error during key derivation. Do NOT count + // this against failed-attempts — otherwise an attacker who can race + // the device-lock state can force a security reset (DoS). + showRetryableError = true + pin = "" + + Logger.security.error("PIN verification failed transiently", metadata: [ + "error": .string(String(describing: error)) + ]) + + case .invalidPin(let failedAttempts): + // PIN verification failed. The repository is the single writer of + // the counter (and the backoff timestamp/baseline); the use case + // already incremented and returns the authoritative count here. + // We only reflect it — we never write it back (M1). showError = true - await setCurrentFailedAttempts(failedAttempts+1) + self.failedAttempts = failedAttempts pin = "" - + Logger.security.warning("PIN verification failed", metadata: [ "attemptCount": .stringConvertible(failedAttempts), "maxAttempts": .stringConvertible(AuthorizationRepository.MAX_FAILED_ATTEMPTS) ]) - + // Check if we've reached the maximum failed attempts if failedAttempts >= AuthorizationRepository.MAX_FAILED_ATTEMPTS { Logger.security.critical("Maximum failed PIN attempts reached, triggering security reset", metadata: [ "attemptCount": .stringConvertible(failedAttempts) ]) - + // Trigger security reset Task { await securityResetUseCase.reset() @@ -153,22 +182,21 @@ final class PINVerificationViewModel: ObservableObject { Logger.security.info("Failed PIN verification", metadata: [ "attemptCount": .stringConvertible(failedAttempts) ]) - - // Check for backoff time after failed attempt + + // Refresh backoff state after the failed attempt. Task { await updateBackoffTime() - await updateCurrentFailedAttempts() } } } } - + func unlockButtonTapped() { Task { await verifyPIN() } } - + // MARK: - Security Methods func clearPinContent() { @@ -177,13 +205,13 @@ final class PINVerificationViewModel: ObservableObject { } // MARK: - Private Methods - + private func updateBackoffTime() async { let remainingSeconds = await authorizationRepository.calculateRemainingBackoffSeconds() - + await MainActor.run { self.backoffSeconds = remainingSeconds - + if remainingSeconds > 0 { startBackoffTimer() Logger.security.info("PIN verification backoff active", metadata: [ @@ -194,26 +222,28 @@ final class PINVerificationViewModel: ObservableObject { } } } - + private func updateCurrentFailedAttempts() async { let attempts = await authorizationRepository.getFailedAttempts() - + await MainActor.run { self.failedAttempts = attempts } } - - private func setCurrentFailedAttempts(_ attempts: Int) async { - await authorizationRepository.setFailedAttempts(attempts) - self.failedAttempts = attempts + + private func loadAlphanumericSetting() async { + // PIN type is a single global preference shared by the app PIN and the + // poison pill, so the unlock screen just reads that flag. + let enabled = await settings.getAlphanumericPinEnabled() + await MainActor.run { self.isAlphanumeric = enabled } } - + private func startBackoffTimer() { stopBackoffTimer() // Stop any existing timer - + backoffTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } - + Task { @MainActor in if self.backoffSeconds > 0 { self.backoffSeconds -= 1 @@ -223,7 +253,7 @@ final class PINVerificationViewModel: ObservableObject { } } } - + private func stopBackoffTimer() { backoffTimer?.invalidate() backoffTimer = nil diff --git a/SnapSafe/Screens/PoisonPillSetup/PoisonPillExplanationView.swift b/SnapSafe/Screens/PoisonPillSetup/PoisonPillExplanationView.swift index 59d991c..6bf0e99 100644 --- a/SnapSafe/Screens/PoisonPillSetup/PoisonPillExplanationView.swift +++ b/SnapSafe/Screens/PoisonPillSetup/PoisonPillExplanationView.swift @@ -25,7 +25,7 @@ struct PoisonPillExplanationView: View { // Header Icon Image(systemName: step.icon) .font(.system(size: 80)) - .foregroundColor(step.iconColor) + .foregroundStyle(step.iconColor) .padding(.top, 20) // Title @@ -68,14 +68,14 @@ struct PoisonPillExplanationView: View { Text(firstLine) .font(.headline) .fontWeight(.semibold) - .foregroundColor(.primary) + .foregroundStyle(.primary) } if lines.count > 1 { let remainingText = lines.dropFirst().joined(separator: "\n") Text(remainingText) .font(.callout) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.leading) } } @@ -97,13 +97,13 @@ struct PoisonPillExplanationView: View { Text(trimmedLine) .font(.title2) .fontWeight(.semibold) - .foregroundColor(step.iconColor) + .foregroundStyle(step.iconColor) .padding(.top, index == 0 ? 0 : 15) } else { // Regular content Text(trimmedLine) .font(.body) - .foregroundColor(.primary) + .foregroundStyle(.primary) .multilineTextAlignment(.leading) .lineSpacing(4) } @@ -132,19 +132,19 @@ struct PoisonPillExplanationView: View { } #Preview("Step 1") { - NavigationView { + NavigationStack { PoisonPillExplanationView(step: ExplanationStep.poisonPillSteps[0]) } } #Preview("Step 2") { - NavigationView { + NavigationStack { PoisonPillExplanationView(step: ExplanationStep.poisonPillSteps[1]) } } #Preview("Step 3") { - NavigationView { + NavigationStack { PoisonPillExplanationView(step: ExplanationStep.poisonPillSteps[2]) } } diff --git a/SnapSafe/Screens/PoisonPillSetup/PoisonPillPinCreationView.swift b/SnapSafe/Screens/PoisonPillSetup/PoisonPillPinCreationView.swift index c360793..6af58ec 100644 --- a/SnapSafe/Screens/PoisonPillSetup/PoisonPillPinCreationView.swift +++ b/SnapSafe/Screens/PoisonPillSetup/PoisonPillPinCreationView.swift @@ -14,117 +14,140 @@ struct PoisonPillPinCreationView: View { @Binding var errorMessage: String @Binding var isLoading: Bool @Environment(\.scenePhase) private var scenePhase + @State private var pinFocused = false + @State private var confirmFocused = false + @State private var revealPin = false + @State private var revealConfirmPin = false + // True while the user is actively entering a PIN (a field is focused). + private var isEntering: Bool { pinFocused || confirmFocused } + + let isAlphanumeric: Bool let canProceed: Bool let onPinChange: (String) -> Void let onConfirmPinChange: (String) -> Void let onSetup: () -> Void let isPinLengthValid: (Int) -> Bool - let onCancel: () -> Void - + + // Reveal the action once the user has started entering a PIN (or while + // setup is in flight) — avoids an idle, disabled button at rest. The + // destructive action stays an explicit tap; it is never auto-submitted. + private var showSetupButton: Bool { + !pin.isEmpty || !confirmPin.isEmpty || isLoading + } + + private var setupButton: some View { + Button { + hideKeyboard() + onSetup() + } label: { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + .foregroundStyle(.white) + } + Text(isLoading ? "Setting up..." : "Setup Poison Pill") + .foregroundStyle(.white) + } + .frame(maxWidth: .infinity) + .padding() + .background(canProceed ? Color.orange : Color.gray) + .clipShape(.rect(cornerRadius: 10)) + } + .disabled(!canProceed) + } + var body: some View { GeometryReader { geometry in ScrollView { VStack(spacing: 30) { - // Header Icon - Image(systemName: "lock.trianglebadge.exclamationmark") - .font(.system(size: 70)) - .foregroundColor(.orange) - .padding(.top, max(30, geometry.safeAreaInsets.top + 20)) - + // Header Icon — slides up out of view once the user focuses a + // field, freeing vertical room for the fields, button, and keypad. + if !isEntering { + Image(systemName: "lock.trianglebadge.exclamationmark") + .font(.system(size: 70)) + .foregroundStyle(.orange) + .padding(.top, max(30, geometry.safeAreaInsets.top + 20)) + .transition(.move(edge: .top).combined(with: .opacity)) + } + // Title Text("Set Poison Pill PIN") .font(.largeTitle) .fontWeight(.bold) - + // Subtitle Text("Create a PIN that will trigger emergency deletion") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) - + // PIN Input Fields VStack(spacing: 20) { - SecureField("Enter new PIN", text: $pin) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .multilineTextAlignment(.center) - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(isPinLengthValid(pin.count) ? Color.orange : Color.gray, lineWidth: 1) - ) - .padding(.horizontal, 50) - .disabled(isLoading) - .opacity(isLoading ? 0.6 : 1.0) - .onChange(of: pin) { _, newValue in - onPinChange(newValue) - } - - SecureField("Confirm PIN", text: $confirmPin) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .multilineTextAlignment(.center) - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(isPinLengthValid(confirmPin.count) ? Color.orange : Color.gray, lineWidth: 1) - ) - .padding(.horizontal, 50) - .disabled(isLoading) - .opacity(isLoading ? 0.6 : 1.0) - .onChange(of: confirmPin) { _, newValue in - onConfirmPinChange(newValue) - } + pinField( + placeholder: "Enter new PIN", + text: $pin, + reveal: $revealPin, + focused: $pinFocused, + onChange: onPinChange + ) + + if !pin.isEmpty && pin.count < 6 { + Text(PINStrings.shortPinWarning) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + .transition(.opacity) + } + + pinField( + placeholder: "Confirm PIN", + text: $confirmPin, + reveal: $revealConfirmPin, + focused: $confirmFocused, + onChange: onConfirmPinChange + ) } - + .animation(.snappy, value: !pin.isEmpty && pin.count < 6) + // Error Message if showError { Text(errorMessage) - .foregroundColor(.red) + .foregroundStyle(.red) .font(.callout) .padding(.top, 5) } - + // Warning HStack { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) + .foregroundStyle(.red) .font(.caption) - Text("When entered, this PIN it will immediately and permanently delete all photos and encryption keys.") + Text( + "When entered, this PIN will immediately and permanently delete all photos and encryption keys." + ) .font(.caption) .fontWeight(.semibold) - .foregroundColor(.red) + .foregroundStyle(.red) } .padding(.horizontal, 30) - - // Action Buttons - VStack(spacing: 15) { - Button(action: { - hideKeyboard() - onSetup() - }) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - .foregroundColor(.white) - } - Text(isLoading ? "Setting up..." : "Setup Poison Pill") - .foregroundColor(.white) - } - .frame(maxWidth: .infinity) - .padding() - .background(canProceed ? Color.orange : Color.gray) - .cornerRadius(10) } - .disabled(!canProceed) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 40) - .padding(.bottom, max(30, geometry.safeAreaInsets.bottom + 20)) + .safeAreaInset(edge: .bottom) { + if showSetupButton { + setupButton + .padding(.horizontal, 40) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.bar) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .frame(maxWidth: .infinity) } + .animation(.snappy, value: showSetupButton) + .animation(.snappy, value: isEntering) } .navigationBarHidden(true) .ignoresSafeArea(.container, edges: []) @@ -140,16 +163,55 @@ struct PoisonPillPinCreationView: View { showError = false } } - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - hideKeyboard() - } + } + + private func pinField( + placeholder: String, + text: Binding, + reveal: Binding, + focused: Binding, + onChange: @escaping (String) -> Void + ) -> some View { + HStack(spacing: 0) { + // Keeps the secure dots visually centered by balancing the trailing eye button. + Image(systemName: "eye") + .opacity(0) + .padding(.leading, 12) + .accessibilityHidden(true) + + RevealableSecureField( + text: text, + isFocused: focused, + placeholder: placeholder, + isAlphanumeric: isAlphanumeric, + maxLength: maxPinLength, + isSecure: !reveal.wrappedValue, + isEnabled: !isLoading + ) + + Button { + reveal.wrappedValue.toggle() + } label: { + Image(systemName: reveal.wrappedValue ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + .padding(.trailing, 12) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityLabel(reveal.wrappedValue ? "Hide PIN" : "Show PIN") + } + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(isPinLengthValid(text.wrappedValue.count) ? Color.orange : Color.gray, lineWidth: 1) + ) + .padding(.horizontal, 50) + .disabled(isLoading) + .opacity(isLoading ? 0.6 : 1.0) + .onChange(of: text.wrappedValue) { _, newValue in + onChange(newValue) } } - + private func hideKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } @@ -161,20 +223,20 @@ struct PoisonPillPinCreationView: View { @Previewable @State var showError = false @Previewable @State var errorMessage = "" @Previewable @State var isLoading = false - - return NavigationView { + + return NavigationStack { PoisonPillPinCreationView( pin: $pin, confirmPin: $confirmPin, showError: $showError, errorMessage: $errorMessage, isLoading: $isLoading, + isAlphanumeric: false, canProceed: false, onPinChange: { _ in }, onConfirmPinChange: { _ in }, onSetup: {}, - isPinLengthValid: { length in length >= 4 && length <= 10 }, - onCancel: {} + isPinLengthValid: { length in length >= 4 && length <= 10 } ) } } diff --git a/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardView.swift b/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardView.swift index 72e0130..bd5b4aa 100644 --- a/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardView.swift +++ b/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardView.swift @@ -18,8 +18,7 @@ struct PoisonPillSetupWizardView: View { } var body: some View { - NavigationView { - VStack(spacing: 0) { + VStack(spacing: 0) { // Progress Indicator progressHeader @@ -40,13 +39,13 @@ struct PoisonPillSetupWizardView: View { Text(viewModel.currentStep == .explanation3 ? "Set Up PIN" : "Continue") .fontWeight(.medium) Image(systemName: "arrow.right") - .font(.system(size: 14, weight: .medium)) + .font(.subheadline) } - .foregroundColor(.white) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background(Color.orange) - .cornerRadius(12) + .clipShape(.rect(cornerRadius: 12)) } .padding(.horizontal, 20) .padding(.top, 20) @@ -55,11 +54,12 @@ struct PoisonPillSetupWizardView: View { .background(Color(UIColor.systemBackground)) } } - .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) .obscuredWhenInactive() .screenCaptureProtected() - } + .task { + await viewModel.loadAlphanumericSetting() + } } // MARK: - Progress Header @@ -71,7 +71,7 @@ struct PoisonPillSetupWizardView: View { Button("Cancel") { handleCancel() } - .foregroundColor(viewModel.isLoading ? .gray : .secondary) + .foregroundStyle(viewModel.isLoading ? .gray : .secondary) .disabled(viewModel.isLoading) Spacer() @@ -86,7 +86,7 @@ struct PoisonPillSetupWizardView: View { Button("Back") { viewModel.goToPreviousStep() } - .foregroundColor(viewModel.isLoading ? .gray : .orange) + .foregroundStyle(viewModel.isLoading ? .gray : .orange) .disabled(viewModel.isLoading) } else { // Invisible button for balance @@ -143,6 +143,7 @@ struct PoisonPillSetupWizardView: View { showError: $viewModel.showError, errorMessage: $viewModel.errorMessage, isLoading: $viewModel.isLoading, + isAlphanumeric: viewModel.isAlphanumeric, canProceed: viewModel.canProceedFromPinCreation, onPinChange: viewModel.updatePIN, onConfirmPinChange: viewModel.updateConfirmPIN, @@ -155,10 +156,7 @@ struct PoisonPillSetupWizardView: View { } } }, - isPinLengthValid: viewModel.isPinLengthValid, - onCancel: { - handleCancel() - } + isPinLengthValid: viewModel.isPinLengthValid ) .transition(.asymmetric( insertion: .move(edge: .trailing), @@ -186,7 +184,5 @@ struct PoisonPillSetupWizardView: View { } #Preview("Step 2 - PIN Creation") { - let view = PoisonPillSetupWizardView() - - //view.viewModel.currentStep = .pinCreation + PoisonPillSetupWizardView() } diff --git a/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardViewModel.swift b/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardViewModel.swift index 44ee231..2859372 100644 --- a/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardViewModel.swift +++ b/SnapSafe/Screens/PoisonPillSetup/PoisonPillSetupWizardViewModel.swift @@ -15,7 +15,7 @@ enum PoisonPillWizardStep: Int, CaseIterable { case explanation2 = 1 case explanation3 = 2 case pinCreation = 3 - + var title: String { switch self { case .explanation1: @@ -32,61 +32,72 @@ enum PoisonPillWizardStep: Int, CaseIterable { @MainActor final class PoisonPillSetupWizardViewModel: ObservableObject { - + // MARK: - Published Properties - + @Published var currentStep: PoisonPillWizardStep = .explanation1 @Published var pin: String = "" @Published var confirmPin: String = "" @Published var showError: Bool = false @Published var errorMessage: String = "" @Published var isLoading: Bool = false - + + /// Global alphanumeric-PIN preference, inherited from settings. The poison + /// pill has no toggle of its own — it follows whatever the app PIN uses. + @Published var isAlphanumeric: Bool = false + // MARK: - Dependencies - - @Injected(\.createPinUseCase) - private var createPinUseCase: CreatePinUseCase - + @Injected(\.createPoisonPillUseCase) private var createPoisonPillUseCase: CreatePoisonPillUseCase - + @Injected(\.pinStrengthCheckUseCase) private var pinStrengthCheckUseCase: PinStrengthCheckUseCase - + + @Injected(\.settingsDataSource) + private var settings: SettingsDataSource + // MARK: - Computed Properties - + var canProceedFromPinCreation: Bool { - return isPinLengthValid(pin.count) && - isPinLengthValid(confirmPin.count) && + return isPinLengthValid(pin.count) && + isPinLengthValid(confirmPin.count) && pin == confirmPin && !isLoading } - + + // MARK: - Alphanumeric preference + /// Seed the PIN type from the persisted global setting. + func loadAlphanumericSetting() async { + isAlphanumeric = await settings.getAlphanumericPinEnabled() + } + // MARK: - PIN Validation Methods - func validateAndFilterPIN(_ newValue: String, isConfirm: Bool = false) -> String { - var filtered = newValue - - // Only allow numbers - filtered = filtered.filter { $0.isNumber } - - // Limit to max digits - if filtered.count > MAX_PIN_LENGTH { - filtered = String(filtered.prefix(MAX_PIN_LENGTH)) + func validateAndFilterPIN(_ newValue: String) -> String { + // Filter to the active PIN type: letters + digits when alphanumeric, + // digits only otherwise. + var filtered = isAlphanumeric + ? newValue.filter { $0.isLetter || $0.isNumber } + : newValue.filter { $0.isNumber } + + // Limit to max length + if filtered.count > maxPinLength { + filtered = String(filtered.prefix(maxPinLength)) } - + return filtered } - + func isPinLengthValid(_ length: Int) -> Bool { - return length >= MIN_PIN_LENGTH && length <= MAX_PIN_LENGTH + return length >= minPinLength && length <= maxPinLength } - + var progressValue: Double { Double(currentStep.rawValue + 1) / Double(PoisonPillWizardStep.allCases.count) } - + // MARK: - Navigation Methods - + func goToNextStep() { withAnimation(.easeInOut(duration: 0.3)) { switch currentStep { @@ -101,7 +112,7 @@ final class PoisonPillSetupWizardViewModel: ObservableObject { } } } - + func goToPreviousStep() { withAnimation(.easeInOut(duration: 0.3)) { switch currentStep { @@ -116,54 +127,47 @@ final class PoisonPillSetupWizardViewModel: ObservableObject { } } } - + // MARK: - PIN Management - + func updatePIN(_ newValue: String) { let filtered = validateAndFilterPIN(newValue) if pin != filtered { pin = filtered } } - + func updateConfirmPIN(_ newValue: String) { let filtered = validateAndFilterPIN(newValue) if confirmPin != filtered { confirmPin = filtered } } - - private func validatePINs() { - showError = false - - if isPinLengthValid(pin.count) && isPinLengthValid(confirmPin.count) && pin != confirmPin { - showError = true - errorMessage = "PINs do not match" - } - } - + func setupPoisonPillPIN() async -> Bool { guard canProceedFromPinCreation else { return false } - + isLoading = true showError = false - - // Check PIN strength - if !pinStrengthCheckUseCase.isPinStrongEnough(pin) { + + // Check PIN strength against the active (global) PIN type. + if !pinStrengthCheckUseCase.isPinStrongEnough(pin, isAlphanumeric: isAlphanumeric) { showError = true - errorMessage = "PIN is too weak. Avoid common patterns like 1234 or repeated digits." + errorMessage = isAlphanumeric + ? "PIN is too weak. Avoid common words and repeated characters." + : "PIN is too weak. Avoid common patterns like 1234 or repeated digits." isLoading = false // Clear PIN fields like in PINSetupViewModel pin = "" confirmPin = "" return false } - + Logger.security.info("Setting up poison pill PIN") let success: Bool = await self.createPoisonPillUseCase.createPin(pppin: pin) - + isLoading = false - + if success { Logger.security.info("Poison pill PIN setup completed successfully") return true @@ -177,9 +181,9 @@ final class PoisonPillSetupWizardViewModel: ObservableObject { return false } } - + // MARK: - Reset - + func reset() { currentStep = .explanation1 pin = "" diff --git a/SnapSafe/Screens/PrivacyShield.swift b/SnapSafe/Screens/PrivacyShield.swift index de7a05e..f4e9b9c 100644 --- a/SnapSafe/Screens/PrivacyShield.swift +++ b/SnapSafe/Screens/PrivacyShield.swift @@ -22,18 +22,19 @@ struct PrivacyShield: View { // App logo/icon Image(systemName: "lock.shield.fill") .font(.system(size: 100)) - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.top, 60) + .accessibilityHidden(true) // App name Text("SnapSafe") - .font(.system(size: 32, weight: .bold)) - .foregroundColor(.white) - + .font(.largeTitle.bold()) + .foregroundStyle(.white) + // Privacy message Text("The camera app that minds its own business.") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.gray) + .font(.title3) + .foregroundStyle(.gray) Spacer() } diff --git a/SnapSafe/Screens/SecurityOverlayView.swift b/SnapSafe/Screens/SecurityOverlayView.swift index 0ed0256..7472231 100644 --- a/SnapSafe/Screens/SecurityOverlayView.swift +++ b/SnapSafe/Screens/SecurityOverlayView.swift @@ -75,23 +75,24 @@ private struct ScreenRecordingBlockerContent: View { // Warning icon Image(systemName: "record.circle") .font(.system(size: 80)) - .foregroundColor(.red) + .foregroundStyle(.red) .padding(.top, 60) + .accessibilityHidden(true) // Warning message Text("Screen Recording Detected") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) + .font(.title2.bold()) + .foregroundStyle(.white) Text("For privacy and security reasons, screen recording is not allowed in SnapSafe.") - .font(.system(size: 16)) - .foregroundColor(.gray) + .font(.callout) + .foregroundStyle(.gray) .multilineTextAlignment(.center) .padding(.horizontal, 40) Text("Please stop recording to continue using the app.") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) + .font(.callout.bold()) + .foregroundStyle(.white) .padding(.top, 20) Spacer() @@ -116,19 +117,20 @@ private struct PrivacyShieldContent: View { // App logo/icon Image(systemName: "lock.shield.fill") .font(.system(size: 100)) - .foregroundColor(.white) + .foregroundStyle(.white) .padding(.top, 60) - + .accessibilityHidden(true) + // App name Text("SnapSafe") - .font(.system(size: 32, weight: .bold)) - .foregroundColor(.white) - + .font(.largeTitle.bold()) + .foregroundStyle(.white) + // Privacy message Text("The camera app that minds its own business.") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.gray) - + .font(.title3) + .foregroundStyle(.gray) + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -190,18 +192,19 @@ struct ScreenshotTakenView: View { VStack { HStack(spacing: 15) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.yellow) - .font(.system(size: 24)) + .foregroundStyle(.yellow) + .font(.title2) + .accessibilityHidden(true) Text("Screenshot Captured") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) + .font(.callout.bold()) + .foregroundStyle(.white) Spacer() } .padding() .background(Color.black.opacity(0.8)) - .cornerRadius(10) + .clipShape(.rect(cornerRadius: 10)) .padding(.horizontal) .padding(.top, 10) diff --git a/SnapSafe/Screens/SecurityOverlayViewModel.swift b/SnapSafe/Screens/SecurityOverlayViewModel.swift index eb78112..698e573 100644 --- a/SnapSafe/Screens/SecurityOverlayViewModel.swift +++ b/SnapSafe/Screens/SecurityOverlayViewModel.swift @@ -12,7 +12,7 @@ import Logging // MARK: - Security Overlay State -public enum SecurityOverlayState { +enum SecurityOverlayState { case normal case screenRecording case requiresAuthentication @@ -180,7 +180,7 @@ final class SecurityOverlayViewModel: ObservableObject { if !hasValidSession, wasInBackground, hasCompletedIntro { Logger.security.info("SecurityOverlay: Requiring authentication after background") - invalidateSessionUseCase.invalidateSession() + await invalidateSessionUseCase.invalidateSession() // Set authentication required flag needsAuthenticationAfterBackground = true @@ -219,6 +219,12 @@ final class SecurityOverlayViewModel: ObservableObject { } private func determineActiveStates() async -> [SecurityOverlayState] { + #if DEBUG + if CommandLine.arguments.contains("-SkipAuthentication") { + return [.normal] + } + #endif + var states: [SecurityOverlayState] = [.normal] // Screen recording takes highest priority diff --git a/SnapSafe/Screens/Settings/SettingsView.swift b/SnapSafe/Screens/Settings/SettingsView.swift index 3ccbeb7..0b9c7ec 100644 --- a/SnapSafe/Screens/Settings/SettingsView.swift +++ b/SnapSafe/Screens/Settings/SettingsView.swift @@ -15,6 +15,9 @@ import FactoryKit struct SettingsView: View { // Appearance setting @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .system + + // Whether videos begin playing automatically when swiped to in the detail view. + @AppStorage("autoPlayVideos") private var autoPlayVideos = true // ViewModel @StateObject private var viewModel = SettingsViewModel() @@ -49,7 +52,7 @@ struct SettingsView: View { Text("When enabled, personal information will be removed from photos before sharing") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.top, 4) } @@ -59,9 +62,18 @@ struct SettingsView: View { Text("Permission Status") Spacer() Text(locationRepository.getAuthorizationStatusString()) - .foregroundColor(viewModel.locationStatusColor) + .foregroundStyle(viewModel.locationStatusColor) } - + + if locationRepository.isAuthorized { + HStack { + Text("Precision") + Spacer() + Text(locationRepository.getAccuracyAuthorizationString()) + .foregroundStyle(.secondary) + } + } + Button { viewModel.requestLocationPermission() } label: { @@ -70,7 +82,7 @@ struct SettingsView: View { Text("When enabled, location data will be embedded in newly captured photos. Location requires permission and GPS availability.") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.top, 4) } @@ -85,7 +97,17 @@ struct SettingsView: View { Text("Choose how the app appears. System follows your device's appearance setting.") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + + // VIDEO SECTION + Section(header: Text("Video")) { + Toggle("Auto-Play Videos", isOn: $autoPlayVideos) + + Text("When on, videos start playing automatically as you swipe to them. When off, they wait paused on the first frame until you tap play.") + .font(.caption) + .foregroundStyle(.secondary) .padding(.top, 4) } @@ -112,27 +134,28 @@ struct SettingsView: View { Text(viewModel.hasPoisonPill ? "Poison pill is configured and ready" : "Set up a special PIN that will immediately delete all photos and encryption keys") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Spacer() Image(systemName: viewModel.hasPoisonPill ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") - .foregroundColor(viewModel.hasPoisonPill ? .green : .orange) - .font(.system(size: 20)) + .foregroundStyle(viewModel.hasPoisonPill ? .green : .orange) + .font(.title3) + .accessibilityHidden(true) } if viewModel.hasPoisonPill { Button("Remove Poison Pill") { viewModel.doShowRemovePoisonPillConfirmation() } - .foregroundColor(.red) + .foregroundStyle(.red) } else { Button("Setup Poison Pill") { nav.dismissAll() nav.navigate(to: .poisonPillSetupWizard) } - .foregroundColor(.orange) + .foregroundStyle(.orange) } } @@ -145,7 +168,7 @@ struct SettingsView: View { Text("Decoy photos will be shown when emergency PIN is entered") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.top, 4) } } @@ -155,12 +178,12 @@ struct SettingsView: View { Button("Perform Security Reset") { viewModel.showSecurityResetConfirmation() } - .foregroundColor(.red) + .foregroundStyle(.red) } footer: { Text("Resets everything, deletes all photos and encryption keys.") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -201,12 +224,13 @@ struct SettingsView: View { // Reset the selection flag when the sheet is dismissed viewModel.stopSelectingDecoys() } content: { - NavigationView { + NavigationStack { // Initialize SecureGalleryView in decoy selection mode SecureGalleryView(selectingDecoys: true, onDismiss: { viewModel.stopSelectingDecoys() }) } + .securityManaged() } } } diff --git a/SnapSafe/Screens/Settings/SettingsViewModel.swift b/SnapSafe/Screens/Settings/SettingsViewModel.swift index e8c82b9..0607ad2 100644 --- a/SnapSafe/Screens/Settings/SettingsViewModel.swift +++ b/SnapSafe/Screens/Settings/SettingsViewModel.swift @@ -23,22 +23,15 @@ final class SettingsViewModel: ObservableObject { // Security settings @Published var sessionTimeout = 5 // minutes - @Published var appPIN = "" - @Published var confirmAppPIN = "" - @Published var poisonPIN = "" @Published var showResetConfirmation = false @Published var hasPoisonPill = false @Published var showRemovePoisonPillConfirmation = false - @Published var showPINError = false - @Published var pinErrorMessage = "" - @Published var showPINSuccess = false // Decoy photos @Published var isSelectingDecoys = false // Location permissions @Published var locationPermissionStatus = "Not Determined" - @Published var includeLocationData = false @Published var shouldOpenSettings = false // MARK: - Dependencies @@ -46,18 +39,12 @@ final class SettingsViewModel: ObservableObject { @Injected(\.pinRepository) private var pinRepository: PinRepository - @Injected(\.authorizationRepository) - private var authorizationRepository: AuthorizationRepository - @Injected(\.locationRepository) private var locationManager: LocationRepository @Injected(\.securityResetUseCase) private var securityResetUseCase: SecurityResetUseCase - @Injected(\.createPoisonPillUseCase) - private var createPoisonPillUseCase: CreatePoisonPillUseCase - @Injected(\.removePoisonPillUseCase) private var removePoisonPillUseCase: RemovePoisonPillUseCase @@ -128,18 +115,6 @@ final class SettingsViewModel: ObservableObject { isSelectingDecoys = false } - /// Save poisin pill PIN - func savePoisonPillPIN() { - if !poisonPIN.isEmpty { - Task { - Logger.storage.info("Setting poison pill PIN") - _ = await createPoisonPillUseCase.createPin(pppin: poisonPIN) - poisonPIN = "" - checkPoisonPillStatus() - } - } - } - /// Check if poison pill is currently configured func checkPoisonPillStatus() { Task { @@ -193,18 +168,7 @@ final class SettingsViewModel: ObservableObject { } var locationPermissionButtonText: String { - let permissionNotDetermined = locationManager.authorizationStatus == .notDetermined - return permissionNotDetermined - ? "Request Location Permission" - : "Manage Permission in Settings" - } - - var isUpdatePINButtonDisabled: Bool { - appPIN.isEmpty || confirmAppPIN.isEmpty - } - - var isSaveEmergencyPINDisabled: Bool { - poisonPIN.isEmpty + "Manage Permission in Settings" } // MARK: - Private Methods diff --git a/SnapSafe/Screens/Views/UIImageExt.swift b/SnapSafe/Screens/Views/UIImageExt.swift index 8e9dadb..a744c41 100644 --- a/SnapSafe/Screens/Views/UIImageExt.swift +++ b/SnapSafe/Screens/Views/UIImageExt.swift @@ -7,21 +7,3 @@ import SwiftUI - -// Extension for UIImage to get an image with the correct orientation applied -extension UIImage { - func imageWithProperOrientation() -> UIImage { - // If already in correct orientation, return self - if self.imageOrientation == .up { - return self - } - - // Create a proper oriented image - UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale) - self.draw(in: CGRect(origin: .zero, size: self.size)) - let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - - return normalizedImage - } -} diff --git a/SnapSafe/Screens/ZoomSliderView.swift b/SnapSafe/Screens/ZoomSliderView.swift index fbda224..7f93e4a 100644 --- a/SnapSafe/Screens/ZoomSliderView.swift +++ b/SnapSafe/Screens/ZoomSliderView.swift @@ -16,6 +16,7 @@ struct ZoomSliderView: View { @State private var hideTimer: Timer? @State private var deviceOrientation = UIDevice.current.orientation @State private var lastDetentLevel: CGFloat? + @State private var hapticTrigger = 0 private let snapThreshold: CGFloat = 0.25 private let hapticThreshold: CGFloat = 0.1 @@ -24,7 +25,7 @@ struct ZoomSliderView: View { // Current zoom level display Text(String(format: "%.1fx", cameraModel.zoomFactor)) .font(.system(size: 16, weight: .bold)) - .foregroundColor(.white) + .foregroundStyle(.white) .rotationEffect(Utils.getRotationAngle()) .animation(.easeInOut, value: deviceOrientation) @@ -36,8 +37,10 @@ struct ZoomSliderView: View { .fill(Color.green.opacity(0.6)) .frame(height: 4) - // Tick marks and labels (tappable) - ForEach(zoomLevels, id: \.self) { level in + // Tick marks and labels (tappable), limited to what the + // current device can actually reach (front cameras have no + // 0.5x ultra-wide lens) + ForEach(zoomLevels.filter { $0 >= cameraModel.minZoom && $0 <= cameraModel.maxZoom }, id: \.self) { level in VStack(spacing: 4) { // Tick mark Rectangle() @@ -47,7 +50,7 @@ struct ZoomSliderView: View { // Label Text(formatZoomLabel(level)) .font(.system(size: 10, weight: level == 1.0 ? .bold : .regular)) - .foregroundColor(.white) + .foregroundStyle(.white) .rotationEffect(Utils.getRotationAngle()) .animation(.easeInOut, value: deviceOrientation) } @@ -92,6 +95,7 @@ struct ZoomSliderView: View { .fill(Color.black.opacity(0.3)) ) .frame(height: 80) + .sensoryFeedback(.impact(weight: .light), trigger: hapticTrigger) .transition(.opacity.combined(with: .scale)) .onAppear { scheduleHide() @@ -99,7 +103,9 @@ struct ZoomSliderView: View { NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main) { _ in - self.deviceOrientation = UIDevice.current.orientation + Task { @MainActor in + self.deviceOrientation = UIDevice.current.orientation + } } } .onDisappear { @@ -219,25 +225,22 @@ struct ZoomSliderView: View { } private func triggerHapticFeedback() { - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() + hapticTrigger += 1 } func scheduleHide() { guard !isDragging && !isPinching else { return } cancelHideTimer() hideTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in - guard !self.isDragging && !self.isPinching else { return } - withAnimation { - self.isVisible = false + Task { @MainActor in + guard !self.isDragging && !self.isPinching else { return } + withAnimation { + self.isVisible = false + } } } } - func keepVisible() { - cancelHideTimer() - } - private func cancelHideTimer() { hideTimer?.invalidate() hideTimer = nil diff --git a/SnapSafe/SnapSafeApp.swift b/SnapSafe/SnapSafeApp.swift index 514c030..beae3d1 100644 --- a/SnapSafe/SnapSafeApp.swift +++ b/SnapSafe/SnapSafeApp.swift @@ -17,6 +17,7 @@ struct SnapSafeApp: App { init() { LoggingConfiguration.configure() + SecurityResetUseCase.cleanupStrandedTempVideos() } var body: some Scene { diff --git a/SnapSafe/Util/AVMetadataItemFactory.swift b/SnapSafe/Util/AVMetadataItemFactory.swift new file mode 100644 index 0000000..494e8c0 --- /dev/null +++ b/SnapSafe/Util/AVMetadataItemFactory.swift @@ -0,0 +1,128 @@ +// +// AVMetadataItemFactory.swift +// SnapSafe +// +// Builds the QuickTime common-metadata items embedded into recorded videos, +// and the inverse helpers used to read them back. Shared by VideoCaptureService +// (write) and SecureImageRepository (read). +// + +import AVFoundation +import CoreLocation +import CoreMedia + +internal enum AVMetadataItemFactory { + + internal static let softwareName = "SnapSafe" + + /// Build the common-metadata items to embed at record-time. The location + /// item is omitted entirely when `location` is nil (permission denied or no + /// fix); no other behavior changes. + internal static func makeCaptureItems(location: CLLocation?, date: Date) -> [AVMetadataItem] { + var items: [AVMetadataItem] = [] + + if let location { + items.append(utf8Item( + identifier: .commonIdentifierLocation, + value: iso6709String( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude))) + } + + items.append(utf8Item( + identifier: .commonIdentifierCreationDate, + value: iso8601Formatter.string(from: date))) + + items.append(utf8Item( + identifier: .commonIdentifierSoftware, + value: softwareName)) + + return items + } + + private static func utf8Item(identifier: AVMetadataIdentifier, value: String) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as NSString + item.dataType = kCMMetadataBaseDataType_UTF8 as String + return item + } + + // MARK: - ISO 6709 (single-point) + + /// ISO 6709 string, e.g. "+37.774900-122.419400/". Six fractional digits + /// preserves the input to well within 1e-6 degrees (the read-path contract). + internal static func iso6709String(latitude: Double, longitude: Double) -> String { + String(format: "%+.6f%+.6f/", latitude, longitude) + } + + private static let iso6709Regex = try? NSRegularExpression( + pattern: #"([+-]\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)"#) + + /// Parse the latitude/longitude out of an ISO 6709 string. Ignores any + /// altitude component and the trailing solidus. Returns nil if the string + /// does not contain two signed decimal numbers. + internal static func parseISO6709(_ string: String) -> GpsCoordinates? { + guard let regex = iso6709Regex else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, range: range), + let latRange = Range(match.range(at: 1), in: string), + let lonRange = Range(match.range(at: 2), in: string), + let lat = Double(string[latRange]), + let lon = Double(string[lonRange]) else { + return nil + } + return GpsCoordinates(latitude: lat, longitude: lon) + } + + // MARK: - Date + + internal static func iso8601Date(from string: String) -> Date? { + iso8601Formatter.date(from: string) + } + + nonisolated(unsafe) private static let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + // MARK: - Codec + + /// Human-readable codec name for a video track's media subtype FourCC. + internal static func codecString(fromMediaSubType subType: FourCharCode) -> String { + switch subType { + case kCMVideoCodecType_HEVC: return "HEVC" + case kCMVideoCodecType_H264: return "H.264" + default: return fourCCString(subType) + } + } + + private static func fourCCString(_ code: FourCharCode) -> String { + let bytes: [UInt8] = [ + UInt8((code >> 24) & 0xFF), + UInt8((code >> 16) & 0xFF), + UInt8((code >> 8) & 0xFF), + UInt8(code & 0xFF) + ] + let string = String(bytes: bytes, encoding: .ascii)? + .trimmingCharacters(in: .whitespaces) + return (string?.isEmpty == false ? string : nil) ?? "Unknown" + } + + // MARK: - Orientation + + /// Decode a video track's preferredTransform into a TiffOrientation, mapping + /// 0/90/180/270 degrees to .up(1)/.right(6)/.down(3)/.left(8). Non-right-angle + /// rotations fall back to .up. + internal static func videoOrientation(fromTransform transform: CGAffineTransform) -> TiffOrientation { + let radians = atan2(transform.b, transform.a) + let degrees = Int((radians * 180 / .pi).rounded()) + switch ((degrees % 360) + 360) % 360 { + case 90: return .right + case 180: return .down + case 270: return .left + default: return .up + } + } +} diff --git a/SnapSafe/Util/Clock.swift b/SnapSafe/Util/Clock.swift index c376eda..21a894a 100644 --- a/SnapSafe/Util/Clock.swift +++ b/SnapSafe/Util/Clock.swift @@ -6,12 +6,31 @@ // +import Foundation + public protocol Clock: Sendable { - var now: Date { get } + /// Wall-clock time. Suitable for display and persistence, but NOT for + /// security-sensitive elapsed-time decisions because the user (or an attacker + /// with device access) can change it. + var now: Date { get } + + /// Monotonic time in seconds since an arbitrary fixed point. Always advances, + /// is unaffected by wall-clock changes, and continues across device sleep. + /// Use this for any elapsed-time check that gates security behavior such as + /// session timeout or PIN backoff. + var monotonicNow: TimeInterval { get } } final class SystemClock: Clock { var now: Date { return Date() } + + var monotonicNow: TimeInterval { + var ts = timespec() + // CLOCK_UPTIME_RAW is monotonic and continues counting while the + // device is asleep, which is what we want for security timers. + clock_gettime(CLOCK_UPTIME_RAW, &ts) + return TimeInterval(ts.tv_sec) + TimeInterval(ts.tv_nsec) / 1_000_000_000 + } } diff --git a/SnapSafe/Util/CombineExt.swift b/SnapSafe/Util/CombineExt.swift index 8550855..45bc6d4 100644 --- a/SnapSafe/Util/CombineExt.swift +++ b/SnapSafe/Util/CombineExt.swift @@ -6,55 +6,3 @@ // import Combine - - -extension Publisher where Output: Sendable { - /// Awaits the first value this publisher emits. - func firstValue() async -> Output? { - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - nonisolated(unsafe) var cancellable: AnyCancellable? - cancellable = self.first().sink( - receiveCompletion: { _ in - continuation.resume(returning: nil) - cancellable?.cancel() - }, - receiveValue: { value in - continuation.resume(returning: value) - cancellable?.cancel() - } - ) - } - } onCancel: { - // Handle task cancellation by cancelling the subscription - } - } - - /// Awaits the first value this publisher emits, or returns `defaultValue` if none are emitted. - func firstValue(or defaultValue: Output) async -> Output { - // Use AsyncSequence bridge - if let value = try? await self.values.first(where: { _ in true }) { - return value - } else { - return defaultValue - } - } -} - -func runBlocking(_ work: @escaping @Sendable () async throws -> T) rethrows -> T { - nonisolated(unsafe) var result: Result! - let semaphore = DispatchSemaphore(value: 0) - - Task { - do { - let value = try await work() - result = .success(value) - } catch { - result = .failure(error) - } - semaphore.signal() - } - - semaphore.wait() - return try! result.get() -} diff --git a/SnapSafe/Util/EncryptedVideoDataSource.swift b/SnapSafe/Util/EncryptedVideoDataSource.swift new file mode 100644 index 0000000..0deb04e --- /dev/null +++ b/SnapSafe/Util/EncryptedVideoDataSource.swift @@ -0,0 +1,340 @@ +// +// EncryptedVideoDataSource.swift +// SnapSafe +// +// Created by Claude on 1/26/26. +// + +import Foundation +import AVFoundation +import CryptoKit +import Logging +import ObjectiveC +import UniformTypeIdentifiers + +/// Custom AVAssetResourceLoaderDelegate for decrypting SECV videos on-the-fly. +/// This enables AVPlayer to play encrypted videos without decrypting the entire file first. +final class EncryptedVideoDataSource: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable { + + private let logger = Logger.video + private let videoURL: URL + private let encryptionKey: SymmetricKey + private var fileSize: UInt64 = 0 + private var trailer: SECVFileFormat.SecvTrailer? + private var chunkCache: [UInt64: Data] = [:] // Simple cache for recently decrypted chunks + private let cacheSizeLimit = 5 // Max chunks to cache + + /// Initialize with encrypted video URL and decryption key. + init(videoURL: URL, encryptionKey: SymmetricKey) { + self.videoURL = videoURL + self.encryptionKey = encryptionKey + super.init() + + // Read metadata immediately + do { + try setupFileAccess() + } catch { + logger.error("Failed to setup encrypted video access", metadata: [ + "error": .string(error.localizedDescription), + "file": .string(videoURL.lastPathComponent) + ]) + } + } + + // MARK: - AVAssetResourceLoaderDelegate + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + logger.debug("Resource loader requested data", metadata: [ + "offset": .stringConvertible(loadingRequest.dataRequest?.requestedOffset ?? 0), + "length": .stringConvertible(loadingRequest.dataRequest?.requestedLength ?? 0) + ]) + + guard let trailer = trailer else { + logger.error("No trailer available - cannot fulfill request") + loadingRequest.finishLoading(with: NSError(domain: "com.snapsafe.video", code: -1, userInfo: [NSLocalizedDescriptionKey: "Video not properly initialized"])) + return false + } + + guard let dataRequest = loadingRequest.dataRequest else { + logger.error("No data request in loading request") + loadingRequest.finishLoading(with: NSError(domain: "com.snapsafe.video", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid loading request"])) + return false + } + + // Handle content information request (metadata about the video) + if loadingRequest.contentInformationRequest != nil { + fulfillContentInformationRequest(loadingRequest.contentInformationRequest!) + } + + // Calculate which chunks are needed for this request + let requestedOffset = UInt64(dataRequest.requestedOffset) + let requestedLength = dataRequest.requestedLength + + logger.debug("Processing data request", metadata: [ + "requestedOffset": .stringConvertible(requestedOffset), + "requestedLength": .stringConvertible(requestedLength), + "chunkSize": .stringConvertible(trailer.chunkSize) + ]) + + // Calculate chunk range needed + let startChunk = requestedOffset / UInt64(trailer.chunkSize) + let endChunk = (requestedOffset + UInt64(requestedLength) - 1) / UInt64(trailer.chunkSize) + + logger.debug("Chunk range calculation", metadata: [ + "startChunk": .stringConvertible(startChunk), + "endChunk": .stringConvertible(endChunk) + ]) + + // Process synchronously on the resource loader queue to avoid + // concurrent file handle access from parallel Tasks. + do { + var fulfilledLength: Int = 0 + var currentOffset = requestedOffset + + for chunkIndex in startChunk...endChunk { + if fulfilledLength >= requestedLength { + break + } + + let chunkPlaintextOffset = SECVFileFormat.calculatePlaintextOffset(chunkIndex: chunkIndex, chunkSize: trailer.chunkSize) + + // Check cache first + if let cachedData = chunkCache[chunkIndex] { + let dataToProvide = getDataFromChunk(cachedData, chunkPlaintextOffset: chunkPlaintextOffset, requestedOffset: currentOffset, requestedLength: requestedLength - fulfilledLength) + + if !dataToProvide.isEmpty { + dataRequest.respond(with: dataToProvide) + fulfilledLength += dataToProvide.count + currentOffset += UInt64(dataToProvide.count) + } + continue + } + + // Read and decrypt chunk (opens its own file handle) + let chunkData = try readAndDecryptChunk(chunkIndex: chunkIndex, trailer: trailer) + + cacheChunk(chunkIndex: chunkIndex, data: chunkData) + + let dataToProvide = getDataFromChunk(chunkData, chunkPlaintextOffset: chunkPlaintextOffset, requestedOffset: currentOffset, requestedLength: requestedLength - fulfilledLength) + + if !dataToProvide.isEmpty { + dataRequest.respond(with: dataToProvide) + fulfilledLength += dataToProvide.count + currentOffset += UInt64(dataToProvide.count) + } + } + + loadingRequest.finishLoading() + + } catch { + logger.error("Failed to fulfill loading request", metadata: [ + "error": .string(error.localizedDescription) + ]) + loadingRequest.finishLoading(with: error) + } + + return true + } + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForResponseTo authenticationChallenge: URLAuthenticationChallenge) -> Bool { + // No authentication needed for local files + return false + } + + // MARK: - Private Methods + + /// Setup file access and read metadata. + private func setupFileAccess() throws { + logger.info("Setting up encrypted video access", metadata: [ + "file": .string(videoURL.lastPathComponent) + ]) + + // Get file size + let attributes = try FileManager.default.attributesOfItem(atPath: videoURL.path) + guard let size = attributes[.size] as? UInt64 else { + throw SECVError.fileIOError + } + fileSize = size + + // Read and parse trailer + let trailerData = try readTrailerData() + trailer = try SECVFileFormat.SecvTrailer.from(data: trailerData) + + logger.info("Video file initialized", metadata: [ + "fileSize": .stringConvertible(fileSize), + "originalSize": .stringConvertible(trailer?.originalSize ?? 0), + "totalChunks": .stringConvertible(trailer?.totalChunks ?? 0) + ]) + } + + /// Read trailer data from end of file. + private func readTrailerData() throws -> Data { + guard fileSize >= UInt64(SECVFileFormat.TRAILER_SIZE) else { + throw SECVError.invalidTrailerSize + } + + let trailerPosition = SECVFileFormat.calculateTrailerPosition(fileLength: fileSize) + let fileHandle = try FileHandle(forReadingFrom: videoURL) + defer { fileHandle.closeFile() } + + try fileHandle.seek(toOffset: trailerPosition) + let trailerData = try fileHandle.read(upToCount: SECVFileFormat.TRAILER_SIZE) + + guard let trailerData = trailerData, trailerData.count == SECVFileFormat.TRAILER_SIZE else { + throw SECVError.invalidTrailerSize + } + + return trailerData + } + + /// Fulfill content information request with video metadata. + private func fulfillContentInformationRequest(_ request: AVAssetResourceLoadingContentInformationRequest) { + guard let trailer = trailer else { + request.contentType = UTType.quickTimeMovie.identifier + request.contentLength = 0 + request.isByteRangeAccessSupported = true + return + } + + request.contentType = UTType.quickTimeMovie.identifier + request.contentLength = Int64(trailer.originalSize) + request.isByteRangeAccessSupported = true + + logger.debug("Fulfilled content information request", metadata: [ + "contentLength": .stringConvertible(request.contentLength) + ]) + } + + /// Read and decrypt a single chunk using its own file handle. + private func readAndDecryptChunk(chunkIndex: UInt64, trailer: SECVFileFormat.SecvTrailer) throws -> Data { + // Calculate where this chunk starts in the encrypted file. + // Each full chunk occupies: IV + chunkSize + authTag bytes. + // The last chunk is smaller: IV + remainingPlaintext + authTag. + let fullEncryptedChunkSize = UInt64(trailer.chunkSize) + UInt64(SECVFileFormat.IV_SIZE) + UInt64(SECVFileFormat.AUTH_TAG_SIZE) + let chunkFileOffset = chunkIndex * fullEncryptedChunkSize + + // Determine actual plaintext size for this chunk (last chunk may be smaller) + let plaintextOffset = chunkIndex * UInt64(trailer.chunkSize) + let remainingPlaintext = trailer.originalSize - plaintextOffset + let thisChunkPlaintextSize = Int(min(UInt64(trailer.chunkSize), remainingPlaintext)) + + // Open a dedicated file handle for this read + let fh = try FileHandle(forReadingFrom: videoURL) + defer { fh.closeFile() } + + try fh.seek(toOffset: chunkFileOffset) + + // Read IV (12 bytes) + guard let ivData = try fh.read(upToCount: SECVFileFormat.IV_SIZE), + ivData.count == SECVFileFormat.IV_SIZE else { + throw SECVError.fileIOError + } + + // Read ciphertext (exact size for this chunk) + guard let ciphertextData = try fh.read(upToCount: thisChunkPlaintextSize), + ciphertextData.count == thisChunkPlaintextSize else { + throw SECVError.fileIOError + } + + // Read authentication tag (16 bytes) + guard let tagData = try fh.read(upToCount: SECVFileFormat.AUTH_TAG_SIZE), + tagData.count == SECVFileFormat.AUTH_TAG_SIZE else { + throw SECVError.fileIOError + } + + let decryptedData = try decryptChunk(ciphertext: ciphertextData, iv: ivData, tag: tagData) + + logger.debug("Decrypted chunk", metadata: [ + "chunkIndex": .stringConvertible(chunkIndex), + "decryptedSize": .stringConvertible(decryptedData.count) + ]) + + return decryptedData + } + + /// Decrypt a chunk using AES-GCM. + private func decryptChunk(ciphertext: Data, iv: Data, tag: Data) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: ciphertext, tag: tag) + return try AES.GCM.open(sealedBox, using: encryptionKey) + } + + /// Get the specific data range from a decrypted chunk. + private func getDataFromChunk(_ chunkData: Data, chunkPlaintextOffset: UInt64, requestedOffset: UInt64, requestedLength: Int) -> Data { + let offsetInChunk = requestedOffset - chunkPlaintextOffset + let remainingInChunk = chunkData.count - Int(offsetInChunk) + let lengthToProvide = min(remainingInChunk, requestedLength) + + guard lengthToProvide > 0 else { + return Data() + } + + let range = Int(offsetInChunk).. cacheSizeLimit { + // Remove oldest chunk (simple FIFO cache) + if let oldestChunkIndex = chunkCache.keys.min() { + chunkCache.removeValue(forKey: oldestChunkIndex) + } + } + + logger.debug("Chunk cached", metadata: [ + "chunkIndex": .stringConvertible(chunkIndex), + "cacheSize": .stringConvertible(chunkCache.count) + ]) + } +} + +// MARK: - AVAsset Extension for Encrypted Videos + +/// Associated-object key used to tie an `EncryptedVideoDataSource`'s lifetime to the +/// asset it decrypts. The variable is never read or written — only its address is +/// used as an opaque token by the Objective-C runtime — so `nonisolated(unsafe)` +/// is safe here. +private nonisolated(unsafe) var encryptedVideoDelegateKey: UInt8 = 0 + +extension AVAsset { + /// Create an AVAsset that can play encrypted SECV videos. + /// Uses a custom URL scheme so AVFoundation routes requests through our delegate + /// instead of trying to read the file directly. + static func makeEncryptedVideoAsset(with encryptedVideoURL: URL, encryptionKey: SymmetricKey) -> AVURLAsset? { + // Build a custom-scheme URL so the resource loader delegate is invoked + var components = URLComponents() + components.scheme = "secv" + components.host = "video" + components.path = "/" + encryptedVideoURL.lastPathComponent + // Stash the real file path as a query param + components.queryItems = [URLQueryItem(name: "path", value: encryptedVideoURL.path)] + + guard let customURL = components.url else { return nil } + + let asset = AVURLAsset(url: customURL) + let delegate = EncryptedVideoDataSource(videoURL: encryptedVideoURL, encryptionKey: encryptionKey) + + asset.resourceLoader.setDelegate(delegate, queue: DispatchQueue(label: "com.snapsafe.videoResourceLoader")) + + // AVAssetResourceLoader only keeps a *weak* reference to its delegate, so the + // delegate must be retained elsewhere for as long as the asset can issue + // loading requests. Tying it to the asset via an associated object (rather + // than the previous shared static dictionary) makes retention both + // thread-safe and self-cleaning: the Objective-C runtime serializes the + // association, and the delegate is released automatically when the asset is + // deallocated — no data race and no unbounded leak. + objc_setAssociatedObject(asset, &encryptedVideoDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN) + + return asset + } + + /// The decrypting delegate retained by `asset`, if it was produced by + /// `makeEncryptedVideoAsset(with:encryptionKey:)`. Exposed for tests that need to + /// assert the delegate's lifetime is bound to the asset. + static func encryptedVideoDataSource(for asset: AVURLAsset) -> EncryptedVideoDataSource? { + objc_getAssociatedObject(asset, &encryptedVideoDelegateKey) as? EncryptedVideoDataSource + } +} \ No newline at end of file diff --git a/SnapSafe/Util/Json.swift b/SnapSafe/Util/Json.swift index 55ce0ea..a8f5e71 100644 --- a/SnapSafe/Util/Json.swift +++ b/SnapSafe/Util/Json.swift @@ -5,12 +5,10 @@ // Created by Adam Brown on 9/4/25. // -public func jsonEncoder() -> JSONEncoder { +func jsonEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return encoder } -public func jsonEncoderFactory() -> JSONEncoder { - return jsonEncoder() -} + diff --git a/SnapSafe/Util/Logger+Extensions.swift b/SnapSafe/Util/Logger+Extensions.swift index 3e33222..5acd961 100644 --- a/SnapSafe/Util/Logger+Extensions.swift +++ b/SnapSafe/Util/Logger+Extensions.swift @@ -25,11 +25,13 @@ extension Logger { /// Logger for general application events static let app = Logger(label: "com.snapsafe.app") + + /// Logger for video operations (encryption, decryption, playback) + static let video = Logger(label: "com.snapsafe.video") + + /// Logger for media gallery operations + static let media = Logger(label: "com.snapsafe.media") - /// Creates a logger with a specific subsystem for more granular logging - static func subsystem(_ name: String, category: String) -> Logger { - return Logger(label: "com.snapsafe.\(category).\(name)") - } } // MARK: - Convenience Methods for Common Patterns @@ -109,20 +111,4 @@ extension Logger { ]) } - /// Log file operations - func logFileOperation(_ operation: String, filePath: String? = nil, fileName: String? = nil, level: Logger.Level = .debug) { - var metadata: Logger.Metadata = [ - "operation": .string(operation) - ] - - if let fileName = fileName { - metadata["file"] = .string(fileName) - } - - if let filePath = filePath { - metadata["path"] = .string(filePath) - } - - self.log(level: level, "File operation", metadata: metadata) - } } \ No newline at end of file diff --git a/SnapSafe/Util/Logging/Logger+Extensions.swift b/SnapSafe/Util/Logging/Logger+Extensions.swift index 01c4a55..7219003 100644 --- a/SnapSafe/Util/Logging/Logger+Extensions.swift +++ b/SnapSafe/Util/Logging/Logger+Extensions.swift @@ -25,11 +25,13 @@ extension Logger { /// Logger for general application events static let app = Logger(label: "com.darkrockstudios.apps.snapsafe.app") + + /// Logger for video recording and encryption operations + static let video = Logger(label: "com.darkrockstudios.apps.snapsafe.video") + + /// Logger for media sharing and export operations + static let media = Logger(label: "com.darkrockstudios.apps.snapsafe.media") - /// Creates a logger with a specific subsystem for more granular logging - static func subsystem(_ name: String, category: String) -> Logger { - return Logger(label: "com.darkrockstudios.apps.snapsafe.\(category).\(name)") - } } // MARK: - Convenience Methods for Common Patterns @@ -109,20 +111,4 @@ extension Logger { ]) } - /// Log file operations - func logFileOperation(_ operation: String, filePath: String? = nil, fileName: String? = nil, level: Logger.Level = .debug) { - var metadata: Logger.Metadata = [ - "operation": .string(operation) - ] - - if let fileName = fileName { - metadata["file"] = .string(fileName) - } - - if let filePath = filePath { - metadata["path"] = .string(filePath) - } - - self.log(level: level, "File operation", metadata: metadata) - } } diff --git a/SnapSafe/Util/Logging/LoggingConfiguration.swift b/SnapSafe/Util/Logging/LoggingConfiguration.swift index 5f7d07f..8ab5468 100644 --- a/SnapSafe/Util/Logging/LoggingConfiguration.swift +++ b/SnapSafe/Util/Logging/LoggingConfiguration.swift @@ -51,44 +51,4 @@ struct LoggingConfiguration { #endif } - /// Update log level dynamically (useful for settings) - static func setLogLevel(_ level: Logger.Level) { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = level - return handler - } - - Logger.app.info("Log level updated", metadata: [ - "new_level": .string(String(describing: level)) - ]) - } -} - -// MARK: - Log Level Utilities -extension Logger.Level { - /// Human-readable description for settings UI - var displayName: String { - switch self { - case .trace: - return "Trace" - case .debug: - return "Debug" - case .info: - return "Info" - case .notice: - return "Notice" - case .warning: - return "Warning" - case .error: - return "Error" - case .critical: - return "Critical" - } - } - - /// All available log levels for settings picker - static var allCases: [Logger.Level] { - return [.trace, .debug, .info, .notice, .warning, .error, .critical] - } } \ No newline at end of file diff --git a/SnapSafe/Util/LoggingConfiguration.swift b/SnapSafe/Util/LoggingConfiguration.swift index 5f7d07f..8ab5468 100644 --- a/SnapSafe/Util/LoggingConfiguration.swift +++ b/SnapSafe/Util/LoggingConfiguration.swift @@ -51,44 +51,4 @@ struct LoggingConfiguration { #endif } - /// Update log level dynamically (useful for settings) - static func setLogLevel(_ level: Logger.Level) { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = level - return handler - } - - Logger.app.info("Log level updated", metadata: [ - "new_level": .string(String(describing: level)) - ]) - } -} - -// MARK: - Log Level Utilities -extension Logger.Level { - /// Human-readable description for settings UI - var displayName: String { - switch self { - case .trace: - return "Trace" - case .debug: - return "Debug" - case .info: - return "Info" - case .notice: - return "Notice" - case .warning: - return "Warning" - case .error: - return "Error" - case .critical: - return "Critical" - } - } - - /// All available log levels for settings picker - static var allCases: [Logger.Level] { - return [.trace, .debug, .info, .notice, .warning, .error, .critical] - } } \ No newline at end of file diff --git a/SnapSafe/Util/MinimumVisibilityGate.swift b/SnapSafe/Util/MinimumVisibilityGate.swift new file mode 100644 index 0000000..46a0fa6 --- /dev/null +++ b/SnapSafe/Util/MinimumVisibilityGate.swift @@ -0,0 +1,88 @@ +// +// MinimumVisibilityGate.swift +// SnapSafe +// +// Created by Claude on 6/12/26. +// + +import Foundation + +/// Drives a transient activity indicator (e.g. the video "Saving…" HUD) that, +/// once shown, stays visible for at least a minimum duration — so an +/// operation that finishes near-instantly reads as a deliberate confirmation +/// instead of an unreadable flash. +/// +/// Show/hide calls are balanced: each `show()` must be paired with a `hide()`, +/// and the indicator only dismisses when the last outstanding `show()` is +/// hidden (a new recording can stop while a previous clip is still +/// encrypting). The minimum duration is measured from when the indicator +/// became visible, so continuously-visible overlapping work never re-arms it. +@MainActor +final class MinimumVisibilityGate: ObservableObject { + + @Published private(set) var isVisible = false + + private let minimumDuration: TimeInterval + private let clock: Clock + private let sleep: @MainActor (TimeInterval) async -> Void + + private var activeCount = 0 + private var shownAtMonotonic: TimeInterval? + private var pendingDismissal: Task? + + /// - Parameters: + /// - minimumDuration: Minimum time the indicator stays visible once shown. + /// - clock: Time source; injectable for tests. + /// - sleep: Suspension primitive for deferred dismissal; injectable for tests. + init( + minimumDuration: TimeInterval, + clock: Clock = SystemClock(), + sleep: @escaping @MainActor (TimeInterval) async -> Void = { try? await Task.sleep(for: .seconds($0)) } + ) { + self.minimumDuration = minimumDuration + self.clock = clock + self.sleep = sleep + } + + /// Marks a unit of work as started and shows the indicator. Cancels any + /// dismissal still counting down from a previous `hide()`. + func show() { + activeCount += 1 + pendingDismissal?.cancel() + pendingDismissal = nil + if !isVisible { + shownAtMonotonic = clock.monotonicNow + isVisible = true + } + } + + /// Marks a unit of work as finished. When the last outstanding unit + /// finishes, hides the indicator — immediately if the minimum duration + /// has already elapsed, otherwise after the remainder. + /// + /// - Returns: The deferred-dismissal task when one was scheduled (tests + /// await it); `nil` when the call was a no-op or hid immediately. + @discardableResult + func hide() -> Task? { + guard activeCount > 0 else { return nil } + activeCount -= 1 + guard activeCount == 0, isVisible else { return nil } + + let elapsed = clock.monotonicNow - (shownAtMonotonic ?? clock.monotonicNow) + let remaining = minimumDuration - elapsed + guard remaining > 0 else { + isVisible = false + return nil + } + + let dismissal = Task { [weak self] in + guard let self else { return } + await self.sleep(remaining) + guard !Task.isCancelled else { return } + self.isVisible = false + self.pendingDismissal = nil + } + pendingDismissal = dismissal + return dismissal + } +} diff --git a/SnapSafe/Util/OrientationManager.swift b/SnapSafe/Util/OrientationManager.swift index 51ed8b5..f84ad1e 100644 --- a/SnapSafe/Util/OrientationManager.swift +++ b/SnapSafe/Util/OrientationManager.swift @@ -7,9 +7,17 @@ import SwiftUI -// NOTE: The single image view is the only place we're doing this now. -// subviews of that view (settings, share...) will be in landscape as well. -// Rotating back out of landscape should return us to the portrait orientation. +// NOTE: The camera asserts `.portrait`, the gallery asserts `.allButUpsideDown`, +// and the single image / video detail view asserts `.allButUpsideDown`. Each +// modifier-bearing view declares its supported orientations on appear; on +// disappear we do NOT reset, so the next appearing view's onAppear owns the +// orientation without an intermediate portrait flash. Screens without a +// modifier inherit whatever the previous screen set; AppDelegate's default +// of `.portrait` covers the very first appearance at app launch. +// +// Rotation is driven through the supported `UIWindowScene.requestGeometryUpdate(_:)` +// API — do NOT set `UIDevice.orientation` directly (a private, unsupported hack +// that iOS rejects on-device with a "BUG IN CLIENT OF UIKIT" log). /// View modifier to control device orientation for specific views struct DeviceRotationViewModifier: ViewModifier { @@ -20,34 +28,13 @@ struct DeviceRotationViewModifier: ViewModifier { .onAppear { AppDelegate.orientationLock = orientations - // Force rotation if needed - if orientations == .portrait { - UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") - } - - // Request geometry update for iOS 16+ + // Force the interface to the requested orientations via the + // supported API. The root VC re-reports its supported set first, + // then the scene geometry request performs the actual rotation. if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + windowScene.windows.first?.rootViewController? + .setNeedsUpdateOfSupportedInterfaceOrientations() windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) - - // Update supported orientations for the view controller - if let viewController = windowScene.windows.first?.rootViewController { - viewController.setNeedsUpdateOfSupportedInterfaceOrientations() - } - } - } - .onDisappear { - // Reset to portrait when leaving - AppDelegate.orientationLock = .portrait - UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") - - // Request geometry update for iOS 16+ - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) - - // Update supported orientations for the view controller - if let viewController = windowScene.windows.first?.rootViewController { - viewController.setNeedsUpdateOfSupportedInterfaceOrientations() - } } } } diff --git a/SnapSafe/Util/UITestDataLoader.swift b/SnapSafe/Util/UITestDataLoader.swift new file mode 100644 index 0000000..c7126d3 --- /dev/null +++ b/SnapSafe/Util/UITestDataLoader.swift @@ -0,0 +1,165 @@ +// Development/testing tool — compiled in Debug builds only, never ships. +#if DEBUG +// +// UITestDataLoader.swift +// SnapSafe +// +// Created by Claude on 10/14/25. +// + +import UIKit +import CoreLocation + +/// Loads test data for UI testing and screenshots +@MainActor +class UITestDataLoader { + + /// Load sample images into the gallery for UI testing + static func loadSampleImages(repository: SecureImageRepository) async { + guard UITestingHelper.isUITesting else { return } + + print("Loading sample images for UI testing...") + + // Check if we already have photos (don't reload if gallery already has images) + let existing = repository.getPhotos() + if !existing.isEmpty { + print("Gallery already has \(existing.count) photos, skipping sample data load") + return + } + + // Generate and save 5 sample images with different characteristics + let sampleImages = [ + (image: generateSampleImage(color: .systemBlue, text: "Mountain", size: CGSize(width: 1200, height: 1600)), + location: CLLocation(latitude: 40.7128, longitude: -74.0060), // New York + title: "Mountain Vista"), + + (image: generateSampleImage(color: .systemGreen, text: "Forest", size: CGSize(width: 1600, height: 1200)), + location: CLLocation(latitude: 34.0522, longitude: -118.2437), // Los Angeles + title: "Forest Path"), + + (image: generateSampleImage(color: .systemOrange, text: "Sunset", size: CGSize(width: 1600, height: 1200)), + location: CLLocation(latitude: 51.5074, longitude: -0.1278), // London + title: "Sunset Beach"), + + (image: generateSampleImage(color: .systemPurple, text: "City", size: CGSize(width: 1200, height: 1600)), + location: CLLocation(latitude: 35.6762, longitude: 139.6503), // Tokyo + title: "City Lights"), + + (image: generateSampleImage(color: .systemTeal, text: "Ocean", size: CGSize(width: 1600, height: 1600)), + location: CLLocation(latitude: -33.8688, longitude: 151.2093), // Sydney + title: "Ocean View") + ] + + // Save each image with a staggered timestamp for realistic ordering + let baseDate = Date().addingTimeInterval(-86400 * 5) // Start 5 days ago + + for (index, sample) in sampleImages.enumerated() { + // Each photo is 1 day newer than the previous + let timestamp = baseDate.addingTimeInterval(TimeInterval(index) * 86400) + + let capturedImage = CapturedImage( + sensorBitmap: sample.image, + timestamp: timestamp, + rotationDegrees: 0 + ) + + do { + let photoDef = try await repository.saveImage( + capturedImage, + location: sample.location, + applyRotation: false, + quality: 0.85 + ) + print("Saved sample image: \(photoDef.photoName) - \(sample.title)") + } catch { + print("Failed to save sample image \(sample.title): \(error)") + } + } + + print("Finished loading \(sampleImages.count) sample images") + } + + /// Generate a colored placeholder image with text overlay + private static func generateSampleImage(color: UIColor, text: String, size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + + let image = renderer.image { context in + // Fill background with gradient + drawGradient(in: context.cgContext, size: size, color: color) + + // Add some decorative elements for visual interest + addDecorativeShapes(context: context.cgContext, size: size, color: color) + + // Add text overlay + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let font = UIFont.systemFont(ofSize: min(size.width, size.height) / 8, weight: .bold) + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.white, + .paragraphStyle: paragraphStyle, + .strokeColor: UIColor.black.withAlphaComponent(0.3), + .strokeWidth: -3.0 + ] + + let textRect = CGRect( + x: 0, + y: (size.height - font.lineHeight) / 2, + width: size.width, + height: font.lineHeight + ) + + text.draw(in: textRect, withAttributes: attributes) + } + + return image + } + + /// Draw a gradient for the background + private static func drawGradient(in context: CGContext, size: CGSize, color: UIColor) { + let darkColor = color.withAlphaComponent(0.8) + let lightColor = color.withAlphaComponent(0.4) + + let colors = [darkColor.cgColor, lightColor.cgColor] as CFArray + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: [0.0, 1.0])! + + // Draw diagonal gradient from top-left to bottom-right + let startPoint = CGPoint(x: 0, y: 0) + let endPoint = CGPoint(x: size.width, y: size.height) + + context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: []) + } + + /// Add decorative shapes to make the image more interesting + private static func addDecorativeShapes(context: CGContext, size: CGSize, color: UIColor) { + context.saveGState() + + // Add some semi-transparent circles for visual interest + let accentColor = color.withAlphaComponent(0.2) + context.setFillColor(accentColor.cgColor) + + // Large circle in top-right + let circle1 = CGRect( + x: size.width * 0.6, + y: -size.height * 0.1, + width: size.height * 0.6, + height: size.height * 0.6 + ) + context.fillEllipse(in: circle1) + + // Medium circle in bottom-left + let circle2 = CGRect( + x: -size.width * 0.1, + y: size.height * 0.5, + width: size.width * 0.5, + height: size.width * 0.5 + ) + context.fillEllipse(in: circle2) + + context.restoreGState() + } +} +#endif diff --git a/SnapSafe/Util/UITestingHelper.swift b/SnapSafe/Util/UITestingHelper.swift new file mode 100644 index 0000000..d725cca --- /dev/null +++ b/SnapSafe/Util/UITestingHelper.swift @@ -0,0 +1,48 @@ +// +// UITestingHelper.swift +// SnapSafe +// +// Created by Claude on 10/13/25. +// + +import Foundation + +/// Helper to detect and configure the app for UI testing +enum UITestingHelper { + + /// Check if the app is running in UI testing mode + static var isUITesting: Bool { + return CommandLine.arguments.contains("-UITesting") + } + + /// Check if authentication should be skipped for testing + static var shouldSkipAuthentication: Bool { + return CommandLine.arguments.contains("-SkipAuthentication") + } + + /// Check if onboarding should be reset for testing + static var shouldResetOnboarding: Bool { + return CommandLine.arguments.contains("-ResetOnboarding") + } + + /// Configure the app for UI testing if needed + static func configureForUITesting() { + guard isUITesting else { return } + + // You can add any global UI testing configuration here + // For example: + // - Disable animations for faster tests + // - Set up mock data + // - Configure network stubbing + + print("App running in UI Testing mode") + + if shouldSkipAuthentication { + print("Skipping authentication for UI tests") + } + + if shouldResetOnboarding { + print("Resetting onboarding for UI tests") + } + } +} diff --git a/SnapSafe/Util/getRotationAngle.swift b/SnapSafe/Util/getRotationAngle.swift index c4b1e18..b3cafae 100644 --- a/SnapSafe/Util/getRotationAngle.swift +++ b/SnapSafe/Util/getRotationAngle.swift @@ -6,37 +6,71 @@ // import SwiftUI +import UIKit -// Get rotation angle for the zoom indicator based on device orientation -public struct Utils { - public static func getRotationAngle() -> Angle { - switch UIDevice.current.orientation { - case .landscapeLeft: - return Angle(degrees: 90) - case .landscapeRight: - return Angle(degrees: -90) - case .portraitUpsideDown: - return Angle(degrees: 180) - default: - return Angle(degrees: 0) // Default to portrait +// Get rotation angle for control glyphs based on device orientation +struct Utils { + /// Live angle for the current physical device orientation (main-actor). + @MainActor static func getRotationAngle() -> Angle { + getRotationAngle(for: UIDevice.current.orientation) + } + + /// Pure mapping from a device orientation to the glyph rotation angle. + /// Non-interface orientations (faceUp/faceDown/unknown) map to upright. + static func getRotationAngle(for orientation: UIDeviceOrientation) -> Angle { + switch orientation { + case .landscapeLeft: return Angle(degrees: 90) + case .landscapeRight: return Angle(degrees: -90) + case .portraitUpsideDown: return Angle(degrees: 180) + default: return Angle(degrees: 0) } } } -extension UIDeviceOrientation { - func getRotationAngle() -> Double { - switch self { - case .portrait: - return 90 // device upright → rotate 90° CW - case .portraitUpsideDown: - return 270 // device upside down → rotate 270° CW - case .landscapeLeft: - return 0 // device rotated left (home button right) → 0° rotation (natural) - case .landscapeRight: - return 180 // device rotated right (home button left) → 180° rotation +/// Publishes the physical device orientation for views that rotate glyphs in +/// place (iOS Camera style). Filters out faceUp/faceDown/unknown so the glyphs +/// don't snap upright when the device is laid flat. +@MainActor +final class OrientationObserver: ObservableObject { + @Published private(set) var orientation: UIDeviceOrientation = .portrait + // nonisolated(unsafe) allows deinit (which is nonisolated) to access the + // token without a Sendable violation. Access is safe because deinit is + // always the last use of the object. + nonisolated(unsafe) private var token: NSObjectProtocol? + + init() { + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + orientation = Self.resolve(incoming: UIDevice.current.orientation, last: .portrait) + token = NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.orientation = Self.resolve( + incoming: UIDevice.current.orientation, + last: self.orientation + ) + } + } + } + + deinit { + if let token { NotificationCenter.default.removeObserver(token) } + } + + /// Pure: keep the incoming orientation when it is a usable interface + /// orientation, otherwise retain the last known value. + nonisolated static func resolve( + incoming: UIDeviceOrientation, + last: UIDeviceOrientation + ) -> UIDeviceOrientation { + switch incoming { + case .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight: + return incoming default: - return 90 // Default to portrait rotation if unknown + return last } } } - diff --git a/SnapSafe/VideoExportTestHelper.swift b/SnapSafe/VideoExportTestHelper.swift new file mode 100644 index 0000000..70b956c --- /dev/null +++ b/SnapSafe/VideoExportTestHelper.swift @@ -0,0 +1,442 @@ +// Development/testing tool — compiled in Debug builds only, never ships. +#if DEBUG +// +// VideoExportTestHelper.swift +// SnapSafe +// +// Created by Assistant on 5/25/26. +// + +import AVFoundation +import Photos +import SwiftUI +import UIKit +import UniformTypeIdentifiers +import CryptoKit + +/// Helper class for testing video export functionality on simulators +/// Since simulators don't have cameras, this provides mock video content for testing +@available(iOS 18.0, *) +class VideoExportTestHelper { + + /// Creates a test video file that can be used for export testing + static func createTestVideoFile() async throws -> URL { + let tempDirectory = FileManager.default.temporaryDirectory + let videoURL = tempDirectory.appendingPathComponent("test_video_\(UUID().uuidString).mp4") + + // Create a simple test video using AVAssetWriter + let writer = try AVAssetWriter(outputURL: videoURL, fileType: .mp4) + + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 1080, + AVVideoHeightKey: 1920, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 6000000 + ] + ] + + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: videoInput, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB + ] + ) + + writer.add(videoInput) + + // Start writing + guard writer.startWriting() else { + throw VideoExportTestError.failedToCreateVideo(writer.error?.localizedDescription ?? "Unknown error") + } + + writer.startSession(atSourceTime: .zero) + + // Generate a short test video (3 seconds) + let totalFrames = 90 // 3 seconds at 30fps + + for frameIndex in 0.. CVPixelBuffer? { + let width = 1080 + let height = 1920 + + var pixelBuffer: CVPixelBuffer? + let result = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_32ARGB, + nil, + &pixelBuffer + ) + + guard result == kCVReturnSuccess, let buffer = pixelBuffer else { + return nil + } + + CVPixelBufferLockBaseAddress(buffer, CVPixelBufferLockFlags(rawValue: 0)) + defer { CVPixelBufferUnlockBaseAddress(buffer, CVPixelBufferLockFlags(rawValue: 0)) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else { + return nil + } + + let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer) + let buffer32 = baseAddress.bindMemory(to: UInt32.self, capacity: height * bytesPerRow / 4) + + // Create an animated gradient + let progress = Float(frameIndex) / Float(totalFrames) + + for y in 0.. Bool { + // Create a test video + let testVideoURL = try await createTestVideoFile() + defer { + try? FileManager.default.removeItem(at: testVideoURL) + } + + // Verify the video was created successfully + guard FileManager.default.fileExists(atPath: testVideoURL.path) else { + throw VideoExportTestError.testVideoNotFound + } + + // Test that the video can be loaded by AVPlayer + let asset = AVURLAsset(url: testVideoURL) + let duration = try await asset.load(.duration) + + guard duration.seconds > 0 else { + throw VideoExportTestError.invalidVideoDuration + } + + // Test exporting to Photos Library (simulator) + return try await testExportToPhotosLibrary(videoURL: testVideoURL) + } + + /// Test exporting video to Photos Library + private static func testExportToPhotosLibrary(videoURL: URL) async throws -> Bool { + // Request authorization first + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + + guard status == .authorized else { + print("⚠️ Photos access not authorized. This is expected in simulator testing.") + return true // Consider this a pass for simulator testing + } + + // Attempt to save the video + return try await withCheckedThrowingContinuation { continuation in + PHPhotoLibrary.shared().performChanges({ + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: videoURL) + }) { success, error in + if let error = error { + continuation.resume(throwing: VideoExportTestError.exportFailed(error.localizedDescription)) + } else { + continuation.resume(returning: success) + } + } + } + } + + /// Create an encrypted test video for testing encrypted video export + static func createEncryptedTestVideo() async throws -> (videoURL: URL, encryptionKey: SymmetricKey) { + // First create a regular test video + let plainVideoURL = try await createTestVideoFile() + defer { + try? FileManager.default.removeItem(at: plainVideoURL) + } + + // Generate encryption key + let encryptionKey = SymmetricKey(size: .bits256) + + // Create encrypted version + let tempDirectory = FileManager.default.temporaryDirectory + let encryptedVideoURL = tempDirectory.appendingPathComponent("encrypted_test_video_\(UUID().uuidString).secv") + + // Read the original video data + let videoData = try Data(contentsOf: plainVideoURL) + + // Create a simple encrypted format (this is a simplified version) + // In your real app, you'd use your SECVFileFormat + let encryptedData = try AES.GCM.seal(videoData, using: encryptionKey) + + // Combine nonce + ciphertext + tag for storage + var combinedData = Data() + combinedData.append(encryptedData.nonce.withUnsafeBytes { Data($0) }) + combinedData.append(encryptedData.ciphertext) + combinedData.append(encryptedData.tag) + + try combinedData.write(to: encryptedVideoURL) + + return (encryptedVideoURL, encryptionKey) + } +} + +/// Test errors for video export functionality +enum VideoExportTestError: Error, LocalizedError { + case failedToCreateVideo(String) + case testVideoNotFound + case invalidVideoDuration + case exportFailed(String) + case encryptionFailed(String) + + var errorDescription: String? { + switch self { + case .failedToCreateVideo(let details): + return "Failed to create test video: \(details)" + case .testVideoNotFound: + return "Test video file was not found after creation" + case .invalidVideoDuration: + return "Test video has invalid duration" + case .exportFailed(let details): + return "Video export failed: \(details)" + case .encryptionFailed(let details): + return "Video encryption failed: \(details)" + } + } +} + +// MARK: - SwiftUI Test View + +/// A SwiftUI view for testing video export functionality in the simulator +@available(iOS 18.0, *) +struct VideoExportTestView: View { + @State private var testStatus = "Ready to test" + @State private var isRunningTest = false + @State private var testResults: [String] = [] + @State private var showingResults = false + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Text("Video Export Simulator Test") + .font(.title2) + .fontWeight(.semibold) + + Text(testStatus) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if isRunningTest { + ProgressView() + .scaleEffect(1.2) + } + + VStack(spacing: 12) { + Button("Test Video Creation") { + runVideoCreationTest() + } + .disabled(isRunningTest) + + Button("Test Video Export") { + runVideoExportTest() + } + .disabled(isRunningTest) + + Button("Test Encrypted Video") { + runEncryptedVideoTest() + } + .disabled(isRunningTest) + + Button("Run All Tests") { + runAllTests() + } + .disabled(isRunningTest) + } + .buttonStyle(.bordered) + + if !testResults.isEmpty { + Button("View Test Results") { + showingResults = true + } + .buttonStyle(.borderedProminent) + } + + Spacer() + + Text("Note: This tests video export functionality without requiring camera hardware. Perfect for simulator testing!") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding() + .navigationTitle("Video Export Test") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingResults) { + TestResultsView(results: testResults) + } + } + } + + private func runVideoCreationTest() { + isRunningTest = true + testStatus = "Creating test video..." + + Task { + #if DEBUG + let result = await VideoExportValidator.validateVideoCreation() + await MainActor.run { + testStatus = result.success ? "✅ Video creation test passed!" : "❌ Video creation test failed" + let emoji = result.success ? "✅" : "❌" + testResults.append("\(emoji) Video Creation: \(result.message)") + isRunningTest = false + } + #else + await MainActor.run { + testStatus = "Tests only available in DEBUG builds" + isRunningTest = false + } + #endif + } + } + + private func runVideoExportTest() { + isRunningTest = true + testStatus = "Testing video export..." + + Task { + #if DEBUG + let result = await VideoExportValidator.validateVideoExport() + await MainActor.run { + testStatus = result.success ? "✅ Video export test passed!" : "❌ Video export test failed" + let emoji = result.success ? "✅" : "❌" + testResults.append("\(emoji) Video Export: \(result.message)") + isRunningTest = false + } + #else + await MainActor.run { + testStatus = "Tests only available in DEBUG builds" + isRunningTest = false + } + #endif + } + } + + private func runEncryptedVideoTest() { + isRunningTest = true + testStatus = "Testing encrypted video creation..." + + Task { + #if DEBUG + let result = await VideoExportValidator.validateEncryptedVideoCreation() + await MainActor.run { + testStatus = result.success ? "✅ Encrypted video test passed!" : "❌ Encrypted video test failed" + let emoji = result.success ? "✅" : "❌" + testResults.append("\(emoji) Encrypted Video: \(result.message)") + isRunningTest = false + } + #else + await MainActor.run { + testStatus = "Tests only available in DEBUG builds" + isRunningTest = false + } + #endif + } + } + + private func runAllTests() { + isRunningTest = true + testResults.removeAll() + testStatus = "Running all tests..." + + Task { + #if DEBUG + let results = await VideoExportValidator.runAllTests() + + await MainActor.run { + for result in results { + let emoji = result.success ? "✅" : "❌" + testResults.append("\(emoji) \(result.testName): \(result.success ? "Success" : result.message)") + } + testStatus = "All tests completed!" + isRunningTest = false + } + #else + await MainActor.run { + testStatus = "Tests only available in DEBUG builds" + isRunningTest = false + } + #endif + } + } +} + +struct TestResultsView: View { + let results: [String] + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List(results, id: \.self) { result in + Text(result) + .font(.body) + } + .navigationTitle("Test Results") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button("Done") { + dismiss() + }) + } + } +} +#endif diff --git a/SnapSafe/VideoExportTests.swift b/SnapSafe/VideoExportTests.swift new file mode 100644 index 0000000..4c34963 --- /dev/null +++ b/SnapSafe/VideoExportTests.swift @@ -0,0 +1,180 @@ +// Development/testing tool — compiled in Debug builds only, never ships. +#if DEBUG +// +// VideoExportTests.swift +// SnapSafe +// +// Created by Assistant on 5/25/26. +// NOTE: This file should be in the test target, not the main app target + +#if DEBUG +import Foundation +import AVFoundation +import Photos +import CryptoKit + +@available(iOS 18.0, *) +class VideoExportValidator { + + static func validateVideoCreation() async -> (success: Bool, message: String) { + do { + let videoURL = try await VideoExportTestHelper.createTestVideoFile() + defer { + try? FileManager.default.removeItem(at: videoURL) + } + + // Verify the file exists + guard FileManager.default.fileExists(atPath: videoURL.path) else { + return (false, "Video file was not created") + } + + // Verify it's a valid video + let asset = AVURLAsset(url: videoURL) + let duration = try await asset.load(.duration) + + guard duration.seconds > 0 else { + return (false, "Video has invalid duration") + } + + guard duration.seconds >= 2.5 else { + return (false, "Video duration is too short") + } + + // Verify video has correct dimensions + let tracks = try await asset.load(.tracks) + let videoTracks = tracks.filter { $0.mediaType == .video } + + guard videoTracks.count > 0 else { + return (false, "Video has no video tracks") + } + + if let videoTrack = videoTracks.first { + let naturalSize = try await videoTrack.load(.naturalSize) + guard naturalSize.width == 1080 && naturalSize.height == 1920 else { + return (false, "Video dimensions are incorrect: \(naturalSize.width)x\(naturalSize.height)") + } + } + + return (true, "Video creation test passed") + + } catch { + return (false, "Video creation failed: \(error.localizedDescription)") + } + } + + static func validateVideoExport() async -> (success: Bool, message: String) { + do { + let success = try await VideoExportTestHelper.testVideoExport() + if success { + return (true, "Video export test passed") + } else { + return (true, "Video export completed with warnings (expected on simulator)") + } + } catch { + return (false, "Video export test failed: \(error.localizedDescription)") + } + } + + static func validateEncryptedVideoCreation() async -> (success: Bool, message: String) { + do { + let (encryptedVideoURL, encryptionKey) = try await VideoExportTestHelper.createEncryptedTestVideo() + defer { + try? FileManager.default.removeItem(at: encryptedVideoURL) + } + + // Verify the encrypted file exists + guard FileManager.default.fileExists(atPath: encryptedVideoURL.path) else { + return (false, "Encrypted video file was not created") + } + + // Verify it has the right extension + guard encryptedVideoURL.pathExtension == "secv" else { + return (false, "Encrypted video has wrong extension: .\(encryptedVideoURL.pathExtension)") + } + + // Verify the file is not empty + let fileSize = try FileManager.default.attributesOfItem(atPath: encryptedVideoURL.path)[.size] as? Int64 + guard (fileSize ?? 0) > 0 else { + return (false, "Encrypted video file is empty") + } + + // Verify encryption key is valid + guard encryptionKey.bitCount == 256 else { + return (false, "Encryption key has wrong bit count: \(encryptionKey.bitCount)") + } + + return (true, "Encrypted video test passed") + + } catch { + return (false, "Encrypted video test failed: \(error.localizedDescription)") + } + } + + static func validateEncryptedVideoPlayer() async -> (success: Bool, message: String) { + do { + let (encryptedVideoURL, encryptionKey) = try await VideoExportTestHelper.createEncryptedTestVideo() + defer { + try? FileManager.default.removeItem(at: encryptedVideoURL) + } + + // Test that we can create an encrypted video asset + let asset = AVAsset.makeEncryptedVideoAsset( + with: encryptedVideoURL, + encryptionKey: encryptionKey + ) + + guard let asset = asset else { + return (false, "Could not create encrypted video asset") + } + + // Test that the asset has the custom scheme + guard asset.url.scheme == "secv" else { + return (false, "Asset does not use custom secv:// scheme: \(asset.url.scheme ?? "nil")") + } + + return (true, "Encrypted video player test passed") + + } catch { + return (false, "Encrypted video player test failed: \(error.localizedDescription)") + } + } + + static func runAllTests() async -> [(testName: String, success: Bool, message: String)] { + var results: [(String, Bool, String)] = [] + + let videoCreation = await validateVideoCreation() + results.append(("Video Creation", videoCreation.success, videoCreation.message)) + + let videoExport = await validateVideoExport() + results.append(("Video Export", videoExport.success, videoExport.message)) + + let encryptedVideo = await validateEncryptedVideoCreation() + results.append(("Encrypted Video", encryptedVideo.success, encryptedVideo.message)) + + let encryptedPlayer = await validateEncryptedVideoPlayer() + results.append(("Encrypted Player", encryptedPlayer.success, encryptedPlayer.message)) + + return results + } +} + +// periphery:ignore +private func getMemoryUsage() -> Int64 { + var taskInfo = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let result: kern_return_t = withUnsafeMutablePointer(to: &taskInfo) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + + if result == KERN_SUCCESS { + return Int64(taskInfo.phys_footprint) + } else { + return 0 + } +} + +#endif +#endif diff --git a/SnapSafe/ViewModels/PINSetupViewModel.swift b/SnapSafe/ViewModels/PINSetupViewModel.swift deleted file mode 100644 index 83dba86..0000000 --- a/SnapSafe/ViewModels/PINSetupViewModel.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// PINSetupViewModel.swift -// SnapSafe -// -// Created by Claude on 9/5/25. -// - -import Foundation -import Combine -import FactoryKit - -@MainActor -final class PINSetupViewModel: ObservableObject { - - // MARK: - Published Properties - @Published var pin: String = "" - @Published var confirmPin: String = "" - @Published var showError: Bool = false - @Published var errorMessage: String = "" - @Published var isLoading: Bool = false - - // MARK: - Computed Properties - var isPINValid: Bool { - pin.count == 4 && confirmPin.count == 4 - } - - var canSubmit: Bool { - isPINValid && !isLoading - } - - // MARK: - Dependencies - @Injected(\.createPinUseCase) private var createPinUseCase: CreatePinUseCase - - // MARK: - Private Properties - private var cancellables = Set() - - // MARK: - Initialization - init() { - setupBindings() - } - - // MARK: - Private Methods - private func setupBindings() { - // Clear error when user starts typing - Publishers.CombineLatest($pin, $confirmPin) - .sink { [weak self] _, _ in - self?.clearError() - } - .store(in: &cancellables) - } - - private func clearError() { - showError = false - errorMessage = "" - } - - // MARK: - PIN Validation Methods - func validateAndFilterPIN(_ newValue: String, isConfirm: Bool = false) -> String { - var filtered = newValue - - // Only allow numbers - filtered = filtered.filter { $0.isNumber } - - // Limit to 4 digits - if filtered.count > 4 { - filtered = String(filtered.prefix(4)) - } - - return filtered - } - - func updatePIN(_ newValue: String) { - pin = validateAndFilterPIN(newValue) - } - - func updateConfirmPIN(_ newValue: String) { - confirmPin = validateAndFilterPIN(newValue) - } - - // MARK: - Business Logic - func createPin() async -> Bool { - guard canSubmit else { return false } - - isLoading = true - defer { isLoading = false } - - // Validate PIN length - if pin.count != 4 { - showError(message: "PIN must be 4 digits") - return false - } - - // Check if PINs match - if pin != confirmPin { - showError(message: "PINs do not match") - return false - } - - // Validate PIN format (already done above, but keeping for clarity) - guard pin.allSatisfy({ $0.isNumber }) else { - showError(message: "PIN must contain only numbers") - return false - } - - // Create the PIN using the use case - let success = await createPinUseCase.createPin(pin) - - if !success { - showError(message: "Failed to create PIN. Please try again.") - return false - } - - return true - } - - // MARK: - Error Handling - private func showError(message: String) { - errorMessage = message - showError = true - } - - // MARK: - Reset Methods - func reset() { - pin = "" - confirmPin = "" - clearError() - isLoading = false - } -} \ No newline at end of file diff --git a/SnapSafeTests/AVMetadataItemFactoryTests.swift b/SnapSafeTests/AVMetadataItemFactoryTests.swift new file mode 100644 index 0000000..12acd8c --- /dev/null +++ b/SnapSafeTests/AVMetadataItemFactoryTests.swift @@ -0,0 +1,110 @@ +// +// AVMetadataItemFactoryTests.swift +// SnapSafeTests +// + +import XCTest +import AVFoundation +import CoreLocation +@testable import SnapSafe + +final class AVMetadataItemFactoryTests: XCTestCase { + + // MARK: - makeCaptureItems + + func testMakeCaptureItemsIncludesLocationCreationDateAndSoftware() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let location = CLLocation(latitude: 37.7749, longitude: -122.4194) + + let items = AVMetadataItemFactory.makeCaptureItems(location: location, date: date) + + let locationItem = AVMetadataItem.metadataItems( + from: items, filteredByIdentifier: .commonIdentifierLocation).first + let dateItem = AVMetadataItem.metadataItems( + from: items, filteredByIdentifier: .commonIdentifierCreationDate).first + let softwareItem = AVMetadataItem.metadataItems( + from: items, filteredByIdentifier: .commonIdentifierSoftware).first + + XCTAssertNotNil(locationItem, "Location item should be present when a location is supplied") + XCTAssertNotNil(dateItem, "Creation date item should always be present") + let softwareValue = try await softwareItem?.load(.stringValue) + XCTAssertEqual(softwareValue, "SnapSafe", + "Software item should be the constant app name") + } + + func testMakeCaptureItemsOmitsLocationWhenNil() { + let items = AVMetadataItemFactory.makeCaptureItems( + location: nil, date: Date(timeIntervalSince1970: 0)) + + let locationItem = AVMetadataItem.metadataItems( + from: items, filteredByIdentifier: .commonIdentifierLocation).first + XCTAssertNil(locationItem, + "Location item must be omitted when no location is available; got \(String(describing: locationItem))") + } + + func testCreationDateItemMatchesInputDate() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let items = AVMetadataItemFactory.makeCaptureItems(location: nil, date: date) + + let dateItem = try XCTUnwrap(AVMetadataItem.metadataItems( + from: items, filteredByIdentifier: .commonIdentifierCreationDate).first) + let rawOptional = try await dateItem.load(.stringValue) + let raw = try XCTUnwrap(rawOptional) + let parsed = try XCTUnwrap(AVMetadataItemFactory.iso8601Date(from: raw)) + + XCTAssertEqual(parsed.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1.0, + "Round-tripped creation date \(parsed) should match input \(date) to the second") + } + + // MARK: - ISO 6709 + + func testISO6709RoundTripPreservesCoordinates() { + let samples: [(Double, Double)] = [ + (37.7749, -122.4194), + (-33.8688, 151.2093), + (0.000001, -0.000002), + (-51.5, -0.12) + ] + for (lat, lon) in samples { + let string = AVMetadataItemFactory.iso6709String(latitude: lat, longitude: lon) + let parsed = AVMetadataItemFactory.parseISO6709(string) + XCTAssertEqual(parsed?.latitude ?? .nan, lat, accuracy: 1e-6, + "latitude round-trip failed for \(string): got \(String(describing: parsed?.latitude))") + XCTAssertEqual(parsed?.longitude ?? .nan, lon, accuracy: 1e-6, + "longitude round-trip failed for \(string): got \(String(describing: parsed?.longitude))") + } + } + + func testParseISO6709ReturnsNilForGarbage() { + XCTAssertNil(AVMetadataItemFactory.parseISO6709("not a coordinate")) + XCTAssertNil(AVMetadataItemFactory.parseISO6709("")) + } + + // MARK: - Codec mapping + + func testCodecStringMapsKnownFourCCs() { + let hevc = AVMetadataItemFactory.codecString(fromMediaSubType: kCMVideoCodecType_HEVC) + XCTAssertEqual(hevc, "HEVC", "Expected HEVC for kCMVideoCodecType_HEVC, got \(hevc)") + let h264 = AVMetadataItemFactory.codecString(fromMediaSubType: kCMVideoCodecType_H264) + XCTAssertEqual(h264, "H.264", "Expected H.264 for kCMVideoCodecType_H264, got \(h264)") + } + + func testCodecStringFallsBackToFourCCStringForUnknownSubType() { + // 'abcd' as a FourCharCode: 0x61626364 + let code: FourCharCode = 0x6162_6364 + let result = AVMetadataItemFactory.codecString(fromMediaSubType: code) + XCTAssertEqual(result, "abcd", "Unknown subtype should fall back to its FourCC string, got \(result)") + } + + // MARK: - Orientation mapping + + func testOrientationFromTransformMapsRotationAngles() { + XCTAssertEqual(AVMetadataItemFactory.videoOrientation(fromTransform: .identity), .up) + XCTAssertEqual(AVMetadataItemFactory.videoOrientation( + fromTransform: CGAffineTransform(rotationAngle: .pi / 2)), .right) + XCTAssertEqual(AVMetadataItemFactory.videoOrientation( + fromTransform: CGAffineTransform(rotationAngle: .pi)), .down) + XCTAssertEqual(AVMetadataItemFactory.videoOrientation( + fromTransform: CGAffineTransform(rotationAngle: -.pi / 2)), .left) + } +} diff --git a/SnapSafeTests/AVPlayerItemStatusObservationTests.swift b/SnapSafeTests/AVPlayerItemStatusObservationTests.swift new file mode 100644 index 0000000..a9c9b9b --- /dev/null +++ b/SnapSafeTests/AVPlayerItemStatusObservationTests.swift @@ -0,0 +1,92 @@ +// +// AVPlayerItemStatusObservationTests.swift +// SnapSafeTests +// +// VideoPlayerView observes AVPlayerItem.status via Combine to drive its loading / +// ready / failed UI. The custom (and racy) Publisher/Subscription that used to back +// this was removed in favour of Foundation's thread-safe `NSObject.publisher(for:)`. +// These tests pin the observable behaviour the view depends on so the refactor — and +// any future one — preserves it. +// + +import XCTest +import AVFoundation +import Combine +@testable import SnapSafe + +final class AVPlayerItemStatusObservationTests: XCTestCase { + + private var cancellables: Set = [] + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } + + /// A fresh AVPlayerItem reports `.unknown`, and the KVO `.initial` option must + /// deliver that current value synchronously on subscribe — the view relies on + /// this to render its initial loading state without waiting for a change. + func test_statusPublisher_emitsCurrentStatusImmediately() { + let item = AVPlayerItem(url: URL(fileURLWithPath: "/dev/null/nonexistent.mov")) + + var received: [AVPlayerItem.Status] = [] + item.publisher(for: \.status) + .sink { received.append($0) } + .store(in: &cancellables) + + XCTAssertEqual(received.first, .unknown, + "Status observation must emit the current status synchronously on subscribe") + } + + /// Driving an item that can't load (no such file) through an AVPlayer must surface + /// a `.failed` transition to subscribers — i.e. the `.new` change path works. + func test_statusPublisher_emitsStatusChange() { + let item = AVPlayerItem(url: URL(fileURLWithPath: "/var/empty/does-not-exist.mov")) + + let failed = expectation(description: "status reaches .failed") + var sawFailure = false + item.publisher(for: \.status) + .sink { status in + if status == .failed, !sawFailure { + sawFailure = true + failed.fulfill() + } + } + .store(in: &cancellables) + + // Status only advances once an AVPlayer attempts to load the item. + let player = AVPlayer(playerItem: item) + player.play() + + wait(for: [failed], timeout: 10) + XCTAssertTrue(sawFailure, "Observers must receive the .failed status change") + } + + /// Cancelling the subscription must tear the observation down cleanly and stop + /// delivery. The previous hand-rolled subscription niled its state in `cancel()` + /// without synchronization; the built-in publisher handles this safely. + func test_statusPublisher_stopsDeliveringAfterCancel() { + let item = AVPlayerItem(url: URL(fileURLWithPath: "/var/empty/does-not-exist.mov")) + + var received: [AVPlayerItem.Status] = [] + let cancellable = item.publisher(for: \.status) + .sink { received.append($0) } + + let countAfterInitial = received.count + XCTAssertGreaterThanOrEqual(countAfterInitial, 1) + + cancellable.cancel() + + // Drive a status change after cancelling; no further values must arrive. + let player = AVPlayer(playerItem: item) + player.play() + + // Give AVFoundation a moment to attempt the load and (would-be) emit. + let settled = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { settled.fulfill() } + wait(for: [settled], timeout: 5) + + XCTAssertEqual(received.count, countAfterInitial, + "No values must be delivered after the subscription is cancelled") + } +} diff --git a/SnapSafeTests/AuthorizationRepositoryTests.swift b/SnapSafeTests/AuthorizationRepositoryTests.swift index c819e01..e07e01d 100644 --- a/SnapSafeTests/AuthorizationRepositoryTests.swift +++ b/SnapSafeTests/AuthorizationRepositoryTests.swift @@ -294,6 +294,44 @@ final class AuthorizationRepositoryTests: XCTestCase { // MARK: Keep-alive + // MARK: Wall-clock manipulation resistance (H4) + + func test_checkSessionValidity_wallClockMovedBackward_doesNotExtendSession() async { + let pin = "1234" + let timeout: Int64 = 1_000 // 1s + + await settings.setAppPin(cipheredPin: pin) + await settings.setSessionTimeout(timeout) + + _ = await authorizePin.authorizePin(pin) + XCTAssertTrue(auth.isAuthorized.firstValue()) + + // Attacker moves the wall clock 1 hour into the past while + // real (monotonic) elapsed time exceeds the 1s session timeout. + clock.advanceWallOnly(by: -3600) + clock.advanceMonotonicOnly(by: 2.0) + + let result = await auth.checkSessionValidity() + + XCTAssertFalse(result, "Session must expire based on monotonic elapsed time, not wall clock") + XCTAssertFalse(auth.isAuthorized.firstValue()) + } + + func test_calculateRemainingBackoffSeconds_wallClockMovedForward_doesNotZeroBackoff() async { + // 3 failed attempts → backoff = 2^(3-1) = 4 seconds + await settings.setFailedPinAttempts(2) + _ = await auth.incrementFailedAttempts() // records monotonic baseline; failed=3 + + // Attacker moves the wall clock 1 hour into the future while + // real (monotonic) time has barely advanced. + clock.advanceWallOnly(by: 3600) + clock.advanceMonotonicOnly(by: 0.5) + + let remaining = await auth.calculateRemainingBackoffSeconds() + + XCTAssertGreaterThan(remaining, 0, "Backoff must remain based on monotonic elapsed time, not wall clock") + } + func test_keepAliveSession_extendsValidity() async { let pin = "1234" let timeout: Int64 = 1_000 // 1s @@ -320,4 +358,59 @@ final class AuthorizationRepositoryTests: XCTestCase { XCTAssertTrue(result) XCTAssertTrue(auth.isAuthorized.firstValue()) } + + // MARK: Concurrency isolation + + func test_concurrentSessionMutations_remainConsistentWithoutCrashing() async { + let pin = "1234" + await settings.setAppPin(cipheredPin: pin) + + // Capture the (now-Sendable) collaborators so the task closures don't + // have to retain the test case itself. + let auth = self.auth! + let authorizePin = self.authorizePin! + + // Hammer the repository from many concurrent tasks. Before the @MainActor + // isolation fix, the auth flag and the monotonic timestamp baselines were + // mutated without synchronization (the type was @unchecked Sendable); this + // was a data race. Now every access is serialized on the main actor, so the + // workload must complete cleanly and settle into a deterministic state. + await withTaskGroup(of: Void.self) { group in + for _ in 0..<200 { + group.addTask { _ = await authorizePin.authorizePin(pin) } + group.addTask { await auth.keepAliveSession() } + group.addTask { _ = await auth.checkSessionValidity() } + group.addTask { await auth.revokeAuthorization() } + } + } + + // Revoke last so the final state is deterministic regardless of interleaving. + await auth.revokeAuthorization() + + let isValid = await auth.checkSessionValidity() + XCTAssertFalse(isValid) + XCTAssertFalse(auth.isAuthorized.firstValue()) + } + + func test_concurrentIncrementFailedAttempts_doesNotLoseUpdates() async { + await settings.setFailedPinAttempts(0) + + let auth = self.auth! + let iterations = 50 + + // Fire many increments concurrently. The previous implementation read the + // count and wrote count+1 across two separate awaits, so interleaved callers + // could read the same value and lose increments — undercounting the lockout. + // With the atomic data-source increment, the final count must equal exactly + // the number of attempts. + await withTaskGroup(of: Void.self) { group in + for _ in 0..! - - override func setUp() { - super.setUp() - cameraModel = CameraModel() - cancellables = Set() - } - - override func tearDown() { - cancellables?.removeAll() - cancellables = nil - cameraModel = nil - super.tearDown() - } - - // MARK: - Initialization Tests - - /// Tests that CameraModel initializes with correct default values - /// Assertion: Should have proper initial state for all camera properties - func testInit_SetsCorrectDefaults() { - XCTAssertFalse(cameraModel.isPermissionGranted, "Permission should initially be false") - XCTAssertNotNil(cameraModel.session, "AVCaptureSession should be initialized") - XCTAssertFalse(cameraModel.alert, "Alert should initially be false") - XCTAssertNotNil(cameraModel.output, "Photo output should be initialized") - XCTAssertNil(cameraModel.recentImage, "Recent image should initially be nil") - XCTAssertEqual(cameraModel.zoomFactor, 1.0, "Zoom factor should default to 1.0") - XCTAssertEqual(cameraModel.minZoom, 0.5, "Min zoom should default to 0.5") - XCTAssertEqual(cameraModel.maxZoom, 10.0, "Max zoom should default to 10.0") - XCTAssertEqual(cameraModel.currentLensType, .wideAngle, "Should default to wide angle lens") - XCTAssertNil(cameraModel.focusIndicatorPoint, "Focus indicator should initially be nil") - XCTAssertFalse(cameraModel.showingFocusIndicator, "Should not show focus indicator initially") - XCTAssertEqual(cameraModel.flashMode, .auto, "Flash mode should default to auto") - XCTAssertEqual(cameraModel.cameraPosition, .back, "Should default to back camera") - } - - /// Tests that CameraModel sets up foreground notification listener correctly - /// Assertion: Should listen for app entering foreground to reset zoom level - func testInit_SetsUpForegroundNotificationListener() { - // This is tested indirectly through the zoom reset functionality - // We can't easily test NotificationCenter observer setup directly - XCTAssertNotNil(cameraModel, "Camera model should initialize without issues") - } - - // MARK: - Permission Handling Tests - - /// Tests that checkPermissions handles simulator environment correctly - /// Assertion: Should grant permission immediately in simulator debug builds - func testCheckPermissions_HandlesSimulatorCorrectly() { - #if DEBUG && targetEnvironment(simulator) - let expectation = XCTestExpectation(description: "Permission should be granted in simulator") - - cameraModel.$isPermissionGranted - .dropFirst() - .sink { isGranted in - if isGranted { - expectation.fulfill() - } - } - .store(in: &cancellables) - - cameraModel.checkPermissions() - - wait(for: [expectation], timeout: 3.0) - #else - // On real device, we can't reliably test permission states without user interaction - XCTAssertTrue(true, "Skipping permission test on real device") - #endif - } - - /// Tests that checkPermissions handles authorized status correctly - /// Assertion: Should set permission granted when already authorized - func testCheckPermissions_HandlesAuthorizedStatus() { - // Note: This test is limited because we can't control AVCaptureDevice authorization status - // In a production app, you might use dependency injection to test this - - cameraModel.checkPermissions() - - // Test completes without crashing - actual permission depends on device/simulator state - XCTAssertNotNil(cameraModel, "Should handle permission check without crashing") - } - - // MARK: - Zoom Control Tests - - /// Tests that zoom factor can be updated correctly - /// Assertion: Should update zoom factor and validate bounds - func testZoomFactor_UpdatesCorrectly() { - let expectation = XCTestExpectation(description: "Zoom factor should update") - - cameraModel.$zoomFactor - .dropFirst() - .sink { zoomFactor in - XCTAssertEqual(zoomFactor, 2.0, "Zoom factor should be updated to 2.0") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.zoomFactor = 2.0 - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests that resetZoomLevel resets zoom to 1.0 - /// Assertion: Should reset zoom factor to default value - func testResetZoomLevel_ResetsToDefault() { - let expectation = XCTestExpectation(description: "Zoom should reset to 1.0") - - // First set zoom to non-default value - cameraModel.zoomFactor = 3.0 - - cameraModel.$zoomFactor - .dropFirst() - .sink { zoomFactor in - if zoomFactor == 1.0 { - expectation.fulfill() - } - } - .store(in: &cancellables) - - cameraModel.resetZoomLevel() - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests that zoom bounds are validated correctly - /// Assertion: Should maintain zoom within min/max bounds - func testZoomBounds_ValidatedCorrectly() { - // Test that zoom factor stays within bounds - let minZoom = cameraModel.minZoom - let maxZoom = cameraModel.maxZoom - - XCTAssertLessThanOrEqual(cameraModel.zoomFactor, maxZoom, "Zoom should not exceed max") - XCTAssertGreaterThanOrEqual(cameraModel.zoomFactor, minZoom, "Zoom should not go below min") - } - - // MARK: - Camera Position Tests - - /// Tests that camera position can be changed - /// Assertion: Should update camera position property - func testCameraPosition_CanBeChanged() { - let expectation = XCTestExpectation(description: "Camera position should change") - - cameraModel.$cameraPosition - .dropFirst() - .sink { position in - XCTAssertEqual(position, .front, "Camera position should change to front") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.cameraPosition = .front - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests that lens type can be changed - /// Assertion: Should update lens type property - func testLensType_CanBeChanged() { - let expectation = XCTestExpectation(description: "Lens type should change") - - cameraModel.$currentLensType - .dropFirst() - .sink { lensType in - XCTAssertEqual(lensType, .ultraWide, "Lens type should change to ultra wide") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.currentLensType = .ultraWide - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Flash Mode Tests - - /// Tests that flash mode can be updated - /// Assertion: Should update flash mode property correctly - func testFlashMode_CanBeUpdated() { - let expectation = XCTestExpectation(description: "Flash mode should update") - - cameraModel.$flashMode - .dropFirst() - .sink { flashMode in - XCTAssertEqual(flashMode, .on, "Flash mode should change to on") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.flashMode = .on - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests all flash mode options - /// Assertion: Should support all standard flash modes - func testFlashMode_SupportsAllOptions() { - let flashModes: [AVCaptureDevice.FlashMode] = [.auto, .on, .off] - - for mode in flashModes { - cameraModel.flashMode = mode - XCTAssertEqual(cameraModel.flashMode, mode, "Should support flash mode: \(mode)") - } - } - - // MARK: - Focus Indicator Tests - - /// Tests that focus indicator can be shown and hidden - /// Assertion: Should update focus indicator visibility correctly - func testFocusIndicator_CanBeShownAndHidden() { - let expectation = XCTestExpectation(description: "Focus indicator should update") - expectation.expectedFulfillmentCount = 2 - - cameraModel.$showingFocusIndicator - .dropFirst() - .sink { showing in - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.showingFocusIndicator = true - cameraModel.showingFocusIndicator = false - - wait(for: [expectation], timeout: 2.0) - } - - /// Tests that focus indicator point can be set - /// Assertion: Should update focus point correctly - func testFocusIndicatorPoint_CanBeSet() { - let expectation = XCTestExpectation(description: "Focus point should update") - let testPoint = CGPoint(x: 100, y: 150) - - cameraModel.$focusIndicatorPoint - .dropFirst() - .sink { point in - XCTAssertEqual(point, testPoint, "Focus point should be set correctly") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.focusIndicatorPoint = testPoint - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Recent Image Tests - - /// Tests that recent image can be set and retrieved - /// Assertion: Should store and retrieve recent image correctly - func testRecentImage_CanBeSetAndRetrieved() { - let expectation = XCTestExpectation(description: "Recent image should update") - let testImage = createTestImage() - - cameraModel.$recentImage - .dropFirst() - .sink { image in - XCTAssertNotNil(image, "Recent image should be set") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.recentImage = testImage - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Alert State Tests - - /// Tests that alert state can be managed correctly - /// Assertion: Should update alert state correctly - func testAlert_CanBeManaged() { - let expectation = XCTestExpectation(description: "Alert state should update") - - cameraModel.$alert - .dropFirst() - .sink { alertShowing in - XCTAssertTrue(alertShowing, "Alert should be showing") - expectation.fulfill() - } - .store(in: &cancellables) - - cameraModel.alert = true - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Session Management Tests - - /// Tests that AVCaptureSession is properly initialized - /// Assertion: Should have valid capture session - func testSession_ProperlyInitialized() { - XCTAssertNotNil(cameraModel.session, "Capture session should be initialized") - } - - /// Tests that photo output is properly initialized - /// Assertion: Should have valid photo output - func testPhotoOutput_ProperlyInitialized() { - XCTAssertNotNil(cameraModel.output, "Photo output should be initialized") - } - - // MARK: - Simulator-Specific Tests - #if DEBUG && targetEnvironment(simulator) - /// Tests that simulator setup works correctly - /// Assertion: Should set up mock camera functionality in simulator - func testSimulatorSetup_WorksCorrectly() { - let expectation = XCTestExpectation(description: "Simulator setup should complete") - - // In simulator, permission should be granted quickly - cameraModel.$isPermissionGranted - .dropFirst() - .sink { isGranted in - if isGranted { - expectation.fulfill() - } - } - .store(in: &cancellables) - - // Call setup directly for testing - cameraModel.checkPermissions() - - wait(for: [expectation], timeout: 3.0) - - // Check that zoom values are set correctly for simulator - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - XCTAssertEqual(self.cameraModel.minZoom, 0.5, "Simulator min zoom should be 0.5") - XCTAssertEqual(self.cameraModel.maxZoom, 10.0, "Simulator max zoom should be 10.0") - XCTAssertEqual(self.cameraModel.zoomFactor, 1.0, "Simulator zoom factor should be 1.0") - } - } - - /// Tests that mock photo capture works in simulator - /// Assertion: Should be able to capture mock photos without camera hardware - func testMockPhotoCapture_WorksInSimulator() { - // Test that the camera model can handle mock photo operations - // Since captureMockPhoto is private, we test indirectly through the public interface - XCTAssertNotNil(cameraModel, "Camera model should work in simulator") - - // Test that recent image can be set (simulating capture) - let mockImage = createTestImage() - cameraModel.recentImage = mockImage - - XCTAssertNotNil(cameraModel.recentImage, "Should be able to set recent image in simulator") - } - #endif - - // MARK: - View Size Tests - - /// Tests that view size can be set and maintained - /// Assertion: Should store view size for camera calculations - func testViewSize_CanBeSetAndMaintained() { - let testSize = CGSize(width: 375, height: 812) - - cameraModel.viewSize = testSize - - XCTAssertEqual(cameraModel.viewSize, testSize, "View size should be maintained") - } - - // MARK: - Memory Management Tests - - /// Tests that camera model properly handles deinitialization - /// Assertion: Should clean up resources without memory leaks - func testDeinit_CleansUpResources() { - // Create and release camera model to test deinit - var testCameraModel: CameraModel? = CameraModel() - XCTAssertNotNil(testCameraModel, "Camera model should be created") - - testCameraModel = nil - XCTAssertNil(testCameraModel, "Camera model should be deallocated") - } - - // MARK: - Published Properties Tests - - /// Tests that all published properties can be observed - /// Assertion: All @Published properties should emit changes correctly - func testPublishedProperties_EmitChangesCorrectly() { - let expectation = XCTestExpectation(description: "Published properties should emit changes") - expectation.expectedFulfillmentCount = 8 // Number of properties we'll test - - // Test multiple published properties - cameraModel.$isPermissionGranted.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$alert.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$zoomFactor.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$currentLensType.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$focusIndicatorPoint.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$showingFocusIndicator.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$flashMode.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - cameraModel.$cameraPosition.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - - // Trigger changes - cameraModel.isPermissionGranted = true - cameraModel.alert = true - cameraModel.zoomFactor = 2.0 - cameraModel.currentLensType = .ultraWide - cameraModel.focusIndicatorPoint = CGPoint(x: 50, y: 50) - cameraModel.showingFocusIndicator = true - cameraModel.flashMode = .on - cameraModel.cameraPosition = .front - - wait(for: [expectation], timeout: 3.0) - } - - // MARK: - Integration Tests - - /// Tests the complete camera initialization flow - /// Assertion: Should handle the full initialization sequence correctly - func testCameraInitializationFlow_CompletesCorrectly() { - let expectation = XCTestExpectation(description: "Camera initialization should complete") - - // Monitor permission changes as indicator of initialization progress - cameraModel.$isPermissionGranted - .dropFirst() - .sink { isGranted in - if isGranted { - expectation.fulfill() - } - } - .store(in: &cancellables) - - // Trigger initialization - cameraModel.checkPermissions() - - wait(for: [expectation], timeout: 5.0) - } - - /// Tests that foreground notification handling works correctly - /// Assertion: Should reset zoom when app enters foreground - func testForegroundNotificationHandling_ResetsZoom() { - // Set zoom to non-default value - cameraModel.zoomFactor = 5.0 - - let expectation = XCTestExpectation(description: "Zoom should reset on foreground") - - cameraModel.$zoomFactor - .dropFirst() - .sink { zoomFactor in - if zoomFactor == 1.0 { - expectation.fulfill() - } - } - .store(in: &cancellables) - - // Simulate app entering foreground - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - wait(for: [expectation], timeout: 2.0) - } - - // MARK: - Error Handling Tests - - /// Tests that camera model handles errors gracefully - /// Assertion: Should not crash when encountering various error conditions - func testErrorHandling_HandlesGracefully() { - // Test that setting invalid values doesn't crash - cameraModel.zoomFactor = -1.0 // Invalid zoom - XCTAssertNotNil(cameraModel, "Should handle invalid zoom without crashing") - - cameraModel.focusIndicatorPoint = CGPoint(x: CGFloat.infinity, y: CGFloat.nan) // Invalid point - XCTAssertNotNil(cameraModel, "Should handle invalid focus point without crashing") - - cameraModel.viewSize = CGSize(width: -100, height: -100) // Invalid size - XCTAssertNotNil(cameraModel, "Should handle invalid view size without crashing") - } - - // MARK: - Helper Methods - - /// Creates a test image for use in tests - private func createTestImage(size: CGSize = CGSize(width: 100, height: 100)) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { context in - context.cgContext.setFillColor(UIColor.red.cgColor) - context.cgContext.fill(CGRect(origin: .zero, size: size)) - } - } -} diff --git a/SnapSafeTests/CameraPreviewLayoutTests.swift b/SnapSafeTests/CameraPreviewLayoutTests.swift new file mode 100644 index 0000000..888a3a3 --- /dev/null +++ b/SnapSafeTests/CameraPreviewLayoutTests.swift @@ -0,0 +1,86 @@ +// +// CameraPreviewLayoutTests.swift +// SnapSafeTests +// + +import CoreGraphics +import XCTest + +@testable import SnapSafe + +final class CameraPreviewLayoutTests: XCTestCase { + + func test_aspectRatio_1080p_isNineSixteenths() { + XCTAssertEqual( + CameraPreviewLayout.portraitAspectRatio(formatWidth: 1920, formatHeight: 1080), + 0.5625, + accuracy: 1e-9 + ) + } + + func test_aspectRatio_fourByThree_format() { + XCTAssertEqual( + CameraPreviewLayout.portraitAspectRatio(formatWidth: 4032, formatHeight: 3024), + 0.75, + accuracy: 1e-9 + ) + } + + func test_aspectRatio_invalidDimensions_fallsBackToNineSixteenths() { + XCTAssertEqual( + CameraPreviewLayout.portraitAspectRatio(formatWidth: 0, formatHeight: 0), + 9.0 / 16.0, + accuracy: 1e-9 + ) + } + + func test_containerSize_fillsWidth_whenHeightFits() { + let size = CameraPreviewLayout.containerSize( + for: CGSize(width: 393, height: 852), + aspectRatio: 0.5625 + ) + XCTAssertEqual(size.width, 393, accuracy: 1e-9) + XCTAssertEqual(size.height, 393 / 0.5625, accuracy: 1e-6) + } + + func test_containerSize_limitsByHeight_whenTooTall() { + let size = CameraPreviewLayout.containerSize( + for: CGSize(width: 393, height: 500), + aspectRatio: 0.5625 + ) + XCTAssertEqual(size.height, 500, accuracy: 1e-9) + XCTAssertEqual(size.width, 500 * 0.5625, accuracy: 1e-9) + } + + // MARK: - largestDimensions(matchingAspectOf:) + + func test_largestDimensions_prefersAspectMatch_overLargerArea() { + // 1920×1080 (16:9) reference: the 4:3 full-sensor entry is bigger by + // area but must lose to the largest 16:9 entry. + let best = CameraPreviewLayout.largestDimensions( + matchingAspectOfWidth: 1920, + height: 1080, + in: [(1920, 1080), (4032, 3024), (4032, 2268)] + ) + XCTAssertEqual(best?.width, 4032) + XCTAssertEqual(best?.height, 2268) + } + + func test_largestDimensions_fallsBackToLargestOverall_whenNoAspectMatch() { + let best = CameraPreviewLayout.largestDimensions( + matchingAspectOfWidth: 1920, + height: 1080, + in: [(3024, 3024), (4032, 3024)] + ) + XCTAssertEqual(best?.width, 4032) + XCTAssertEqual(best?.height, 3024) + } + + func test_largestDimensions_emptyCandidates_returnsNil() { + XCTAssertNil(CameraPreviewLayout.largestDimensions( + matchingAspectOfWidth: 1920, + height: 1080, + in: [] + )) + } +} diff --git a/SnapSafeTests/CameraZoomMappingTests.swift b/SnapSafeTests/CameraZoomMappingTests.swift new file mode 100644 index 0000000..59af9b8 --- /dev/null +++ b/SnapSafeTests/CameraZoomMappingTests.swift @@ -0,0 +1,101 @@ +// +// CameraZoomMappingTests.swift +// SnapSafeTests +// +// Mapping between user-facing ("display") zoom factors and the virtual +// device's videoZoomFactor space. On a virtual device (dual-wide/triple +// camera), videoZoomFactor 1.0 is the ultra-wide lens at full FOV; the wide +// lens engages at the first switch-over factor (typically 2.0), which is what +// users see as "1.0x". +// + +import Foundation +import XCTest + +@testable import SnapSafe + +final class CameraZoomMappingTests: XCTestCase { + + // MARK: - Dual-wide camera (ultra-wide + wide, switch-over at 2.0) + + func test_dualWideCamera_displayRangeCoversUltraWide() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0], maxDeviceZoom: 16.0) + + XCTAssertEqual(mapping.minDisplayZoom, 0.5) + XCTAssertEqual(mapping.maxDisplayZoom, 8.0) // 16.0 device / 2.0, under the 10x cap + } + + func test_dualWideCamera_displayOneIsTheWideLensSwitchOver() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0], maxDeviceZoom: 16.0) + + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 1.0), 2.0) + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 0.5), 1.0) + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 2.0), 4.0) + } + + func test_dualWideCamera_deviceZoomRoundTripsToDisplayZoom() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0], maxDeviceZoom: 16.0) + + XCTAssertEqual(mapping.displayZoom(forDeviceZoom: 2.0), 1.0, accuracy: 1e-9) + XCTAssertEqual(mapping.displayZoom(forDeviceZoom: 1.0), 0.5, accuracy: 1e-9) + XCTAssertEqual(mapping.displayZoom(forDeviceZoom: 6.0), 3.0, accuracy: 1e-9) + } + + // MARK: - Triple camera (ultra-wide + wide + telephoto) + + func test_tripleCamera_wideLensIsTheFirstSwitchOver() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0, 6.0], maxDeviceZoom: 123.0) + + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 1.0), 2.0) + // Telephoto engages at device 6.0 == display 3.0; the mapping is linear across it. + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 3.0), 6.0) + } + + func test_tripleCamera_displayZoomIsCappedAtTenX() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0, 6.0], maxDeviceZoom: 123.0) + + XCTAssertEqual(mapping.maxDisplayZoom, 10.0) + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 10.0), 20.0) + } + + // MARK: - Wide-only device (no ultra-wide: front camera, older hardware) + + func test_wideOnlyDevice_usesIdentityMapping() { + let mapping = CameraZoomMapping(switchOverFactors: [], maxDeviceZoom: 6.0) + + XCTAssertEqual(mapping.minDisplayZoom, 1.0) + XCTAssertEqual(mapping.maxDisplayZoom, 6.0) + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 2.0), 2.0) + XCTAssertEqual(mapping.displayZoom(forDeviceZoom: 3.0), 3.0, accuracy: 1e-9) + } + + // MARK: - Clamping + + func test_deviceZoom_clampsDisplayZoomToValidRange() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0], maxDeviceZoom: 16.0) + + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 0.3), 1.0) // below ultra-wide floor + XCTAssertEqual(mapping.deviceZoom(forDisplayZoom: 50.0), 16.0) // above device max + } + + func test_clampedDisplayZoom_clampsToDisplayRange() { + let mapping = CameraZoomMapping(switchOverFactors: [2.0], maxDeviceZoom: 16.0) + + XCTAssertEqual(mapping.clampedDisplayZoom(0.3), 0.5) + XCTAssertEqual(mapping.clampedDisplayZoom(50.0), 8.0) + XCTAssertEqual(mapping.clampedDisplayZoom(1.7), 1.7) + } + + // MARK: - Defensive handling of degenerate values + + func test_invalidSwitchOverFactors_fallBackToIdentityMapping() { + // A zero/negative switch-over must never produce divide-by-zero or an + // inverted range. + let zero = CameraZoomMapping(switchOverFactors: [0.0], maxDeviceZoom: 8.0) + XCTAssertEqual(zero.minDisplayZoom, 1.0) + XCTAssertEqual(zero.deviceZoom(forDisplayZoom: 2.0), 2.0) + + let negative = CameraZoomMapping(switchOverFactors: [-2.0], maxDeviceZoom: 8.0) + XCTAssertEqual(negative.minDisplayZoom, 1.0) + } +} diff --git a/SnapSafeTests/DecoyVideoIntegrationTests.swift b/SnapSafeTests/DecoyVideoIntegrationTests.swift new file mode 100644 index 0000000..f4de7d5 --- /dev/null +++ b/SnapSafeTests/DecoyVideoIntegrationTests.swift @@ -0,0 +1,179 @@ +// +// DecoyVideoIntegrationTests.swift +// SnapSafeTests +// +// End-to-end test of marking a video as a decoy using the REAL +// VideoEncryptionService (not the fake). This is what drives the gallery decoy +// badge: the badge shows iff isDecoyVideo(videoDef) is true, which requires +// addDecoyVideoWithKey to actually create the decoy file. +// + +import XCTest +import CryptoKit +@testable import SnapSafe + +@MainActor +final class DecoyVideoIntegrationTests: XCTestCase { + + private var tempDirectory: URL! + private var videosDirectory: URL! + + override func setUp() async throws { + try await super.setUp() + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + videosDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videosDir) + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: tempDirectory) + tempDirectory = nil + videosDirectory = nil + try await super.tearDown() + } + + func testMarkingVideoAsDecoyCreatesDecoyWithRealEncryption() async throws { + let videoService = VideoEncryptionService() + // FakeEncryptionScheme.getDerivedKey() returns 32 zero bytes; encrypt the + // source video with that same key so addDecoyVideoWithKey can decrypt it. + let currentKey = SymmetricKey(data: Data(count: 32)) + + // Create a plaintext "video" and encrypt it into the videos directory, + // exactly as the camera does (pre-create the output, then encrypt). + let plainURL = tempDirectory.appendingPathComponent("plain.mov") + try Data(repeating: 0x42, count: 8192).write(to: plainURL) + + let videoFile = videosDirectory.appendingPathComponent("video_20260530_000000.secv") + FileManager.default.createFile(atPath: videoFile.path, contents: nil) + try await videoService.encryptVideoForDecoy(inputURL: plainURL, outputURL: videoFile, encryptionKey: currentKey) + + let videoDef = VideoDef( + videoName: "video_20260530_000000", + videoFormat: "secv", + videoFile: videoFile + ) + + let repo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: videoService, + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + + // When — mark the video as a decoy (real decrypt + re-encrypt). + let success = await repo.addDecoyVideoWithKey(videoDef, keyData: Data(repeating: 0xAB, count: 32)) + + // Then + XCTAssertTrue(success, "Marking a video as a decoy must succeed with the real encryption service") + let isDecoy = await repo.isDecoyVideo(videoDef) + XCTAssertTrue(isDecoy, + "isDecoyVideo must be true after marking — this is what drives the gallery decoy badge") + } + + /// After the poison pill is removed (RemovePoisonPillUseCase calls + /// removeAllDecoyVideos), a previously-decoy video must no longer report as + /// decoy. Otherwise the gallery keeps showing a stale "shield" badge on the + /// thumbnail. + func testRemoveAllDecoyVideosClearsDecoyState() async throws { + let videoService = VideoEncryptionService() + let currentKey = SymmetricKey(data: Data(count: 32)) + + let plainURL = tempDirectory.appendingPathComponent("plain.mov") + try Data(repeating: 0x42, count: 8192).write(to: plainURL) + + let videoFile = videosDirectory.appendingPathComponent("video_20260614_000000.secv") + FileManager.default.createFile(atPath: videoFile.path, contents: nil) + try await videoService.encryptVideoForDecoy(inputURL: plainURL, outputURL: videoFile, encryptionKey: currentKey) + + let videoDef = VideoDef( + videoName: "video_20260614_000000", + videoFormat: "secv", + videoFile: videoFile + ) + + let repo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: videoService, + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + + let marked = await repo.addDecoyVideoWithKey(videoDef, keyData: Data(repeating: 0xAB, count: 32)) + XCTAssertTrue(marked, "precondition: video must be markable as decoy") + let isDecoyBefore = await repo.isDecoyVideo(videoDef) + XCTAssertTrue(isDecoyBefore, "precondition: isDecoyVideo must be true after marking, got \(isDecoyBefore)") + + await repo.removeAllDecoyVideos() + + let isDecoyAfter = await repo.isDecoyVideo(videoDef) + XCTAssertFalse(isDecoyAfter, + "isDecoyVideo must be false after removeAllDecoyVideos — otherwise the gallery keeps showing the decoy badge. Got \(isDecoyAfter)") + } + + /// Regression for the SECV decrypt bug: encrypt then decrypt must recover the + /// original bytes exactly for a single (partial) chunk. + func testVideoEncryptDecryptRoundTripSingleChunk() async throws { + try await assertRoundTrip(plaintext: Data((0..<8192).map { UInt8($0 & 0xFF) })) + } + + /// The case the bug actually broke: a multi-chunk file with a partial final + /// chunk. Decrypt used to read a full chunkSize for the last chunk, swallowing + /// the auth tag and throwing fileIOError. + func testVideoEncryptDecryptRoundTripMultiChunkPartialLast() async throws { + let size = SECVFileFormat.DEFAULT_CHUNK_SIZE + 5000 // 1 full chunk + a partial one + try await assertRoundTrip(plaintext: Data((0.. URL { + // The file need not exist: EncryptedVideoDataSource.init tolerates a missing + // file (it logs and leaves the trailer nil), and these tests only exercise + // delegate creation/lifetime, not actual decryption. + FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString).secv") + } + + /// The delegate must live exactly as long as the asset. A permanent static cache + /// (the previous implementation) would keep it alive forever, leaking the + /// delegate and its decrypted-chunk cache for every video ever played. + func test_delegate_isReleasedWhenAssetDeallocates() throws { + weak var weakDelegate: EncryptedVideoDataSource? + + try autoreleasepool { + let key = SymmetricKey(size: .bits256) + var asset: AVURLAsset? = AVAsset.makeEncryptedVideoAsset( + with: makeTempSecvURL(), encryptionKey: key) + + weakDelegate = AVAsset.encryptedVideoDataSource(for: try XCTUnwrap(asset)) + XCTAssertNotNil(weakDelegate, "Delegate must be retained while the asset is alive") + + asset = nil + } + + XCTAssertNil(weakDelegate, + "Delegate must be released when its asset deallocates; the old static cache leaked it") + } + + /// Creating many assets concurrently must not corrupt shared state. The previous + /// implementation mutated a shared `Dictionary` from every call with no lock, + /// which is a data race; the associated-object approach removes the shared state + /// entirely, so this must complete cleanly with every delegate retained. + func test_concurrentCreation_isRaceFreeAndRetainsEachDelegate() async { + let key = SymmetricKey(size: .bits256) + let count = 200 + + let successes = await withTaskGroup(of: Bool.self) { group -> Int in + for i in 0.. EnhancedPhotoDetailViewModel { + EnhancedPhotoDetailViewModel(allMedia: [], initialIndex: 0) + } + + // MARK: - Latching + + func test_verticalFirstMovement_latchesDismissing_andTracksFinger() { + let vm = makeViewModel() + + vm.handleDragChanged(translation: CGSize(width: 5, height: 30), geometryHeight: 800) + + XCTAssertEqual(vm.dragMode, .dismissing) + XCTAssertTrue(vm.isDismissDragging) + XCTAssertEqual(vm.dragOffset, CGSize(width: 5, height: 30)) + } + + func test_horizontalFirstMovement_latchesRejected_andNeverMoves() { + let vm = makeViewModel() + + vm.handleDragChanged(translation: CGSize(width: 40, height: 5), geometryHeight: 800) + + XCTAssertEqual(vm.dragMode, .rejected) + XCTAssertEqual(vm.dragOffset, .zero) + + // A later vertical-dominant update must NOT re-engage mid-gesture. + vm.handleDragChanged(translation: CGSize(width: 40, height: 200), geometryHeight: 800) + + XCTAssertEqual(vm.dragMode, .rejected) + XCTAssertEqual(vm.dragOffset, .zero) + XCTAssertEqual(vm.dismissProgress, 0) + } + + func test_dismissingKeepsTrackingBothAxes_whenHorizontalDominates() { + let vm = makeViewModel() + + vm.handleDragChanged(translation: CGSize(width: 0, height: 30), geometryHeight: 800) + // The old implementation froze here (|width| > |height|). + vm.handleDragChanged(translation: CGSize(width: 120, height: 40), geometryHeight: 800) + + XCTAssertEqual(vm.dragMode, .dismissing) + XCTAssertEqual(vm.dragOffset, CGSize(width: 120, height: 40)) + } + + // MARK: - Progress + + func test_dismissProgress_scalesWithDownwardTravel_andClamps() { + let vm = makeViewModel() + + vm.handleDragChanged(translation: CGSize(width: 0, height: 160), geometryHeight: 800) + // 160 / (800 * 0.4) = 0.5 + XCTAssertEqual(vm.dismissProgress, 0.5, accuracy: 1e-9) + + vm.handleDragChanged(translation: CGSize(width: 0, height: 1000), geometryHeight: 800) + XCTAssertEqual(vm.dismissProgress, 1.0) + } + + func test_upwardDrag_clampsProgressToZero() { + let vm = makeViewModel() + + vm.handleDragChanged(translation: CGSize(width: 0, height: -50), geometryHeight: 800) + + XCTAssertEqual(vm.dragMode, .dismissing) + XCTAssertEqual(vm.dismissProgress, 0) + // The image still follows the finger upward. + XCTAssertEqual(vm.dragOffset, CGSize(width: 0, height: -50)) + } + + // MARK: - Gesture end + + func test_dragEnd_belowThreshold_springsBack_andResetsLatch() { + let vm = makeViewModel() + vm.handleDragChanged(translation: CGSize(width: 10, height: 100), geometryHeight: 800) + + vm.handleDragEnded( + translation: CGSize(width: 10, height: 100), + verticalVelocity: 0, + geometryHeight: 800 + ) { XCTFail("must not dismiss below threshold") } + + XCTAssertEqual(vm.dragMode, .undecided) + XCTAssertFalse(vm.isDismissDragging) + XCTAssertEqual(vm.dragOffset, .zero) + XCTAssertEqual(vm.dismissProgress, 0) + } + + func test_dragEnd_pastThreshold_callsDismiss() async { + let vm = makeViewModel() + let dismissed = expectation(description: "dismiss called") + vm.handleDragChanged(translation: CGSize(width: 0, height: 300), geometryHeight: 800) + + // 300 > 800 * 0.25 + vm.handleDragEnded( + translation: CGSize(width: 0, height: 300), + verticalVelocity: 0, + geometryHeight: 800 + ) { dismissed.fulfill() } + + // The dismiss fires from a Task after a 100ms sleep; the async + // fulfillment API services main-actor jobs while waiting. + await fulfillment(of: [dismissed], timeout: 2.0) + XCTAssertEqual(vm.dragMode, .undecided) + } + + func test_dragEnd_afterRejectedGesture_resetsLatchForNextGesture() { + let vm = makeViewModel() + vm.handleDragChanged(translation: CGSize(width: 40, height: 5), geometryHeight: 800) + XCTAssertEqual(vm.dragMode, .rejected) + + vm.handleDragEnded( + translation: CGSize(width: 40, height: 5), + verticalVelocity: 0, + geometryHeight: 800 + ) { XCTFail("rejected gesture must not dismiss") } + XCTAssertEqual(vm.dragMode, .undecided) + + // Next gesture can latch fresh. + vm.handleDragChanged(translation: CGSize(width: 0, height: 30), geometryHeight: 800) + XCTAssertEqual(vm.dragMode, .dismissing) + } + + // MARK: - Chrome + + func test_overlayOpacity_isZero_whileDismissDragging() { + let vm = makeViewModel() + vm.handleDragChanged(translation: CGSize(width: 0, height: 10), geometryHeight: 800) + + XCTAssertEqual(vm.overlayOpacity, 0) + } +} diff --git a/SnapSafeTests/FaceDetectorTests.swift b/SnapSafeTests/FaceDetectorTests.swift deleted file mode 100644 index 3c9b8ae..0000000 --- a/SnapSafeTests/FaceDetectorTests.swift +++ /dev/null @@ -1,385 +0,0 @@ -// -// FaceDetectorTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/25/25. -// - -import XCTest -import UIKit -import Vision -@testable import SnapSafe - -class FaceDetectorTests: XCTestCase { - - private var faceDetector: FaceDetector! - private var testImage: UIImage! - - override func setUp() { - super.setUp() - faceDetector = FaceDetector() - testImage = createTestImage() - } - - override func tearDown() { - faceDetector = nil - testImage = nil - super.tearDown() - } - - // MARK: - Face Detection Tests - - /// Tests that detectFaces() handles nil CGImage gracefully - /// Assertion: Should return empty array when image cannot be converted to CGImage - func testDetectFaces_HandlesInvalidImage() { - let expectation = XCTestExpectation(description: "Face detection should complete") - - // Create image with no CGImage backing - let invalidImage = UIImage() - - faceDetector.detectFaces(in: invalidImage) { detectedFaces in - XCTAssertTrue(detectedFaces.isEmpty, "Should return empty array for invalid image") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 2.0) - } - - /// Tests that detectFaces() processes valid images asynchronously - /// Assertion: Should complete without throwing and return results via completion handler - func testDetectFaces_ProcessesValidImageAsynchronously() { - let expectation = XCTestExpectation(description: "Face detection should complete") - - faceDetector.detectFaces(in: testImage) { detectedFaces in - // Should complete without crashing - XCTAssertNotNil(detectedFaces, "Should return non-nil array") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - /// Tests that detectFaces() returns DetectedFace objects with proper coordinate conversion - /// Assertion: Detected faces should have bounds within image dimensions - func testDetectFaces_ReturnsValidCoordinates() { - let expectation = XCTestExpectation(description: "Face detection should complete") - - faceDetector.detectFaces(in: testImage) { detectedFaces in - for face in detectedFaces { - // Assert face bounds are within image dimensions - XCTAssertGreaterThanOrEqual(face.bounds.minX, 0, "Face X coordinate should be >= 0") - XCTAssertGreaterThanOrEqual(face.bounds.minY, 0, "Face Y coordinate should be >= 0") - XCTAssertLessThanOrEqual(face.bounds.maxX, self.testImage.size.width, - "Face should be within image width") - XCTAssertLessThanOrEqual(face.bounds.maxY, self.testImage.size.height, - "Face should be within image height") - - // Assert face has positive dimensions - XCTAssertGreaterThan(face.bounds.width, 0, "Face width should be positive") - XCTAssertGreaterThan(face.bounds.height, 0, "Face height should be positive") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - /// Tests that detectFaces() handles Vision framework errors gracefully - /// Assertion: Should return empty array when Vision processing fails - func testDetectFaces_HandlesVisionErrors() { - let expectation = XCTestExpectation(description: "Face detection should handle errors") - - // Create a very small image that might cause Vision issues - let tinyImage = createTestImage(size: CGSize(width: 1, height: 1)) - - faceDetector.detectFaces(in: tinyImage) { detectedFaces in - // Should not crash and return some result - XCTAssertNotNil(detectedFaces, "Should return array even on potential Vision errors") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 3.0) - } - - // MARK: - Face Masking Tests - - /// Tests that maskFaces() returns original image when no faces are selected - /// Assertion: Should return original image unchanged when no faces are selected for masking - func testMaskFaces_ReturnsOriginalWhenNoFacesSelected() { - let face1 = DetectedFace(bounds: CGRect(x: 10, y: 10, width: 50, height: 50), isSelected: false) - let face2 = DetectedFace(bounds: CGRect(x: 100, y: 100, width: 60, height: 60), isSelected: false) - let faces = [face1, face2] - - let result = faceDetector.maskFaces(in: testImage, faces: faces, modes: [.blur]) - - XCTAssertNotNil(result, "Should return a valid image") - // Note: Exact pixel comparison is complex, so we verify basic properties - XCTAssertEqual(result?.size, testImage.size, "Result should have same dimensions as original") - } - - /// Tests that maskFaces() returns original image when modes array is empty - /// Assertion: Should return original image when no masking modes are specified - func testMaskFaces_ReturnsOriginalWhenNoModes() { - let face = DetectedFace(bounds: CGRect(x: 10, y: 10, width: 50, height: 50), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: []) - - XCTAssertNotNil(result, "Should return a valid image") - XCTAssertEqual(result?.size, testImage.size, "Result should have same dimensions as original") - } - - /// Tests that maskFaces() processes selected faces with blur mode - /// Assertion: Should return modified image when faces are selected and blur mode is applied - func testMaskFaces_ProcessesSelectedFacesWithBlur() { - let selectedFace = DetectedFace(bounds: CGRect(x: 50, y: 50, width: 100, height: 100), isSelected: true) - let unselectedFace = DetectedFace(bounds: CGRect(x: 200, y: 200, width: 80, height: 80), isSelected: false) - let faces = [selectedFace, unselectedFace] - - let result = faceDetector.maskFaces(in: testImage, faces: faces, modes: [.blur]) - - XCTAssertNotNil(result, "Should return a valid blurred image") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - /// Tests that maskFaces() handles blackout mode correctly - /// Assertion: Should apply blackout effect to selected faces - func testMaskFaces_AppliesBlackoutMode() { - let face = DetectedFace(bounds: CGRect(x: 25, y: 25, width: 50, height: 50), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blackout]) - - XCTAssertNotNil(result, "Should return image with blackout effect") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - /// Tests that maskFaces() handles pixelate mode correctly - /// Assertion: Should apply pixelation effect to selected faces - func testMaskFaces_AppliesPixelateMode() { - let face = DetectedFace(bounds: CGRect(x: 30, y: 30, width: 60, height: 60), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.pixelate]) - - XCTAssertNotNil(result, "Should return image with pixelation effect") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - /// Tests that maskFaces() handles noise mode correctly - /// Assertion: Should apply noise effect to selected faces - func testMaskFaces_AppliesNoiseMode() { - let face = DetectedFace(bounds: CGRect(x: 40, y: 40, width: 70, height: 70), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.noise]) - - XCTAssertNotNil(result, "Should return image with noise effect") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - /// Tests that maskFaces() handles multiple selected faces - /// Assertion: Should apply masking to all selected faces - func testMaskFaces_HandlesMultipleSelectedFaces() { - let face1 = DetectedFace(bounds: CGRect(x: 20, y: 20, width: 40, height: 40), isSelected: true) - let face2 = DetectedFace(bounds: CGRect(x: 80, y: 80, width: 50, height: 50), isSelected: true) - let face3 = DetectedFace(bounds: CGRect(x: 150, y: 150, width: 45, height: 45), isSelected: false) - let faces = [face1, face2, face3] - - let result = faceDetector.maskFaces(in: testImage, faces: faces, modes: [.blur]) - - XCTAssertNotNil(result, "Should return image with multiple faces masked") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - /// Tests that maskFaces() uses first mode when multiple modes are provided - /// Assertion: Should use primary (first) mode for processing when multiple modes are specified - func testMaskFaces_UsesPrimaryModeFromMultipleModes() { - let face = DetectedFace(bounds: CGRect(x: 35, y: 35, width: 55, height: 55), isSelected: true) - - // Provide multiple modes - should use first one (blur) - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blur, .pixelate, .blackout]) - - XCTAssertNotNil(result, "Should return image processed with primary mode") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - // MARK: - Helper Method Tests - - /// Tests that coerceRectToImage() properly constrains rectangles within image bounds - /// Assertion: Should return rectangle that is always within image boundaries - func testCoerceRectToImage_ConstrainsRectangleWithinBounds() { - // Use reflection to access private method for testing - let method = class_getInstanceMethod(FaceDetector.self, Selector(("coerceRectToImage:image:"))) -// XCTAssertNotNil(method, "coerceRectToImage method should exist") - - // Test with rectangle extending outside image bounds - let oversizedRect = CGRect(x: -10, y: -10, width: testImage.size.width + 20, height: testImage.size.height + 20) - - // Since we can't easily access private method, we'll test the public behavior - // by creating a face that would require coercion - let face = DetectedFace(bounds: oversizedRect, isSelected: true) - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blackout]) - - // Should not crash and should return valid image - XCTAssertNotNil(result, "Should handle oversized rectangles without crashing") - } - - /// Tests that coerceRectToImage() handles completely outside rectangles - /// Assertion: Should create small valid rectangle when input is completely outside image - func testCoerceRectToImage_HandlesCompletelyOutsideRectangles() { - // Test with rectangle completely outside image - let outsideRect = CGRect(x: testImage.size.width + 100, y: testImage.size.height + 100, width: 50, height: 50) - let face = DetectedFace(bounds: outsideRect, isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blackout]) - - // Should handle gracefully without crashing - XCTAssertNotNil(result, "Should handle completely outside rectangles") - } - - // MARK: - Blur Faces Convenience Method Tests - - /// Tests that blurFaces() is a convenience wrapper for maskFaces() with blur mode - /// Assertion: Should apply blur masking to selected faces - func testBlurFaces_IsConvenienceWrapperForBlurMode() { - let face = DetectedFace(bounds: CGRect(x: 45, y: 45, width: 65, height: 65), isSelected: true) - - let result = faceDetector.blurFaces(in: testImage, faces: [face]) - - XCTAssertNotNil(result, "blurFaces should return valid result") - XCTAssertEqual(result?.size, testImage.size, "Result should maintain original dimensions") - } - - // MARK: - Image Processing Algorithm Tests - - /// Tests that pixelate algorithm maintains image structure while reducing detail - /// Assertion: Pixelated image should have similar overall structure but reduced detail - func testPixelateAlgorithm_MaintainsImageStructure() { - let face = DetectedFace(bounds: CGRect(x: 60, y: 60, width: 80, height: 80), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.pixelate]) - - XCTAssertNotNil(result, "Pixelation should produce valid result") - // Pixelated image should still be recognizable as an image - XCTAssertEqual(result?.size, testImage.size, "Pixelated image should maintain size") - } - - /// Tests that blur algorithm produces smoothed regions - /// Assertion: Blurred regions should lose sharp detail while maintaining general appearance - func testBlurAlgorithm_ProducesSmoothRegions() { - let face = DetectedFace(bounds: CGRect(x: 70, y: 70, width: 90, height: 90), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blur]) - - XCTAssertNotNil(result, "Blur should produce valid result") - XCTAssertEqual(result?.size, testImage.size, "Blurred image should maintain size") - } - - /// Tests that noise algorithm generates random pattern - /// Assertion: Noise effect should replace image data with random values - func testNoiseAlgorithm_GeneratesRandomPattern() { - let face = DetectedFace(bounds: CGRect(x: 55, y: 55, width: 75, height: 75), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.noise]) - - XCTAssertNotNil(result, "Noise should produce valid result") - XCTAssertEqual(result?.size, testImage.size, "Noise image should maintain size") - } - - // MARK: - Memory and Performance Tests - - /// Tests that face detection completes within reasonable time - /// Assertion: Face detection should complete within performance threshold - func testFaceDetection_CompletesWithinReasonableTime() { - let expectation = XCTestExpectation(description: "Face detection should complete quickly") - let startTime = Date() - - faceDetector.detectFaces(in: testImage) { _ in - let elapsedTime = Date().timeIntervalSince(startTime) - XCTAssertLessThan(elapsedTime, 10.0, "Face detection should complete within 10 seconds") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 15.0) - } - - /// Tests that masking operations complete efficiently - /// Assertion: Face masking should not cause significant delay or memory issues - func testFaceMasking_CompletesEfficiently() { - let face = DetectedFace(bounds: CGRect(x: 50, y: 50, width: 100, height: 100), isSelected: true) - - measure { - let _ = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blur]) - } - } - - /// Tests that multiple masking operations don't cause memory leaks - /// Assertion: Should handle multiple operations without excessive memory growth - func testMultipleMaskingOperations_HandleMemoryEfficiently() { - let face = DetectedFace(bounds: CGRect(x: 40, y: 40, width: 80, height: 80), isSelected: true) - - // Perform multiple operations to test memory handling - for _ in 0..<10 { - let result = faceDetector.maskFaces(in: testImage, faces: [face], modes: [.blur]) - XCTAssertNotNil(result, "Each operation should succeed") - } - } - - // MARK: - Edge Case Tests - - /// Tests that very small face rectangles are handled correctly - /// Assertion: Should handle faces with minimal dimensions without errors - func testVerySmallFaceRectangles_HandledCorrectly() { - let tinyFace = DetectedFace(bounds: CGRect(x: 10, y: 10, width: 1, height: 1), isSelected: true) - - let result = faceDetector.maskFaces(in: testImage, faces: [tinyFace], modes: [.blur]) - - XCTAssertNotNil(result, "Should handle very small face rectangles") - } - - /// Tests that very large face rectangles are handled correctly - /// Assertion: Should handle faces that cover most of the image - func testVeryLargeFaceRectangles_HandledCorrectly() { - let largeFace = DetectedFace( - bounds: CGRect(x: 5, y: 5, width: testImage.size.width - 10, height: testImage.size.height - 10), - isSelected: true - ) - - let result = faceDetector.maskFaces(in: testImage, faces: [largeFace], modes: [.blackout]) - - XCTAssertNotNil(result, "Should handle very large face rectangles") - } - - /// Tests that zero-sized rectangles are handled gracefully - /// Assertion: Should not crash with zero-width or zero-height rectangles - func testZeroSizedRectangles_HandledGracefully() { - let zeroWidthFace = DetectedFace(bounds: CGRect(x: 50, y: 50, width: 0, height: 50), isSelected: true) - let zeroHeightFace = DetectedFace(bounds: CGRect(x: 100, y: 100, width: 50, height: 0), isSelected: true) - - let result1 = faceDetector.maskFaces(in: testImage, faces: [zeroWidthFace], modes: [.blur]) - let result2 = faceDetector.maskFaces(in: testImage, faces: [zeroHeightFace], modes: [.blur]) - - XCTAssertNotNil(result1, "Should handle zero-width rectangles") - XCTAssertNotNil(result2, "Should handle zero-height rectangles") - } - - // MARK: - Helper Methods - - /// Creates a test image for use in tests - private func createTestImage(size: CGSize = CGSize(width: 300, height: 300)) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { context in - // Create a simple gradient background - context.cgContext.setFillColor(UIColor.blue.cgColor) - context.cgContext.fill(CGRect(origin: .zero, size: size)) - - // Add some geometric shapes to make it more interesting for Vision - context.cgContext.setFillColor(UIColor.white.cgColor) - context.cgContext.fillEllipse(in: CGRect(x: size.width * 0.3, y: size.height * 0.3, - width: size.width * 0.4, height: size.height * 0.4)) - - context.cgContext.setFillColor(UIColor.black.cgColor) - context.cgContext.fillEllipse(in: CGRect(x: size.width * 0.4, y: size.height * 0.4, - width: size.width * 0.1, height: size.height * 0.1)) - context.cgContext.fillEllipse(in: CGRect(x: size.width * 0.5, y: size.height * 0.4, - width: size.width * 0.1, height: size.height * 0.1)) - } - } -} diff --git a/SnapSafeTests/FileBasedSettingsDataSourceProtectionTests.swift b/SnapSafeTests/FileBasedSettingsDataSourceProtectionTests.swift new file mode 100644 index 0000000..46e11b7 --- /dev/null +++ b/SnapSafeTests/FileBasedSettingsDataSourceProtectionTests.swift @@ -0,0 +1,48 @@ +// +// FileBasedSettingsDataSourceProtectionTests.swift +// SnapSafeTests +// +// Created by Claude on 2026-06-01. +// +// H2 (Option D) hardening: the settings file persists the reversible +// poison-pill PIN ciphertext (and the primary/poison-pill PIN hashes). It must +// be written with complete file protection so it is unreadable while the device +// is locked, not just excluded from backup. File protection is not enforced on +// the simulator, so this runs on a device. + +import Foundation +import XCTest + +@testable import SnapSafe + +final class FileBasedSettingsDataSourceProtectionTests: XCTestCase { + + private var fileURL: URL! + + override func setUp() async throws { + try await super.setUp() + fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("h2-settings-\(UUID().uuidString).json") + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: fileURL) + try await super.tearDown() + } + + func test_settingsFile_hasCompleteFileProtection() async throws { + #if targetEnvironment(simulator) + throw XCTSkip("File protection is not enforced on iOS Simulator; verify on a real device") + #else + // Creating the data source writes the file (init saves defaults). + let store = FileBasedSettingsDataSource(fileURL: fileURL) + + // Persist a poison-pill secret so the file holds the sensitive value. + await store.setPoisonPillPin(cipheredHashedPin: "hashed", cipheredPlainPin: "plain") + + let values = try fileURL.resourceValues(forKeys: [.fileProtectionKey]) + XCTAssertEqual(values.fileProtection, .complete, + "Settings file must have .complete file protection (holds reversible PP PIN)") + #endif + } +} diff --git a/SnapSafeTests/HardwareEncryptionSchemeFileProtectionTests.swift b/SnapSafeTests/HardwareEncryptionSchemeFileProtectionTests.swift new file mode 100644 index 0000000..f9573eb --- /dev/null +++ b/SnapSafeTests/HardwareEncryptionSchemeFileProtectionTests.swift @@ -0,0 +1,124 @@ +// +// HardwareEncryptionSchemeFileProtectionTests.swift +// SnapSafeTests +// +// Created by Claude on 2026-05-31. +// + +import Foundation +import Mockable +import Security +import XCTest + +@testable import SnapSafe + +final class HardwareEncryptionSchemeFileProtectionTests: XCTestCase { + private var tempDir: URL! + private var deviceInfo: MockDeviceInfoDataSource! + private var scheme: HardwareEncryptionScheme! + private var testKeyAlias: String! + private let hashedPin = HashedPin(hash: "dGVzdGhhc2g=", salt: "dGVzdHNhbHQ=") + + override func setUp() async throws { + try await super.setUp() + + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + deviceInfo = MockDeviceInfoDataSource() + given(deviceInfo).getDeviceIdentifier().willReturn(Data("test-device-id".utf8)) + + // Isolated KEK alias so creating a DEK here never touches the production + // snapsafe_kek on a device. + testKeyAlias = "snapsafe_kek_test_\(UUID().uuidString)" + scheme = HardwareEncryptionScheme(deviceInfo: deviceInfo, keyAlias: testKeyAlias) + } + + override func tearDown() async throws { + // Scoped cleanup: only this test's isolated key + DEK file. + if let scheme { await scheme.evictKey() } + if let testKeyAlias { Self.deleteHardwareKey(alias: testKeyAlias) } + if let scheme { try? FileManager.default.removeItem(at: scheme.getDekFile(hashedPin: hashedPin)) } + if let tempDir { try? FileManager.default.removeItem(at: tempDir) } + try await super.tearDown() + } + + private static func deleteHardwareKey(alias: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: alias.data(using: .utf8)!, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom + ] + SecItemDelete(query as CFDictionary) + } + + func test_keyDirectory_hasCompleteFileProtection() async throws { + #if targetEnvironment(simulator) + throw XCTSkip("File protection is not enforced on iOS Simulator; verify on a real device") + #else + let keyDir = scheme.getKeyDirectory() + + let resourceValues = try keyDir.resourceValues(forKeys: [.fileProtectionKey]) + let protection = resourceValues.fileProtection + + XCTAssertEqual(protection, .complete, "Keys directory should have .complete file protection") + #endif + } + + func test_keyDirectory_isExcludedFromBackup() async throws { + let keyDir = scheme.getKeyDirectory() + + let resourceValues = try keyDir.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertTrue(resourceValues.isExcludedFromBackup ?? false, "Keys directory should be excluded from backup") + } + + func test_dekFile_hasCompleteFileProtection_afterCreation() async throws { + #if targetEnvironment(simulator) + throw XCTSkip("File protection is not enforced on iOS Simulator; verify on a real device") + #else + let testPin = "1234" + let hashedPin = HashedPin(hash: "dGVzdGhhc2g=", salt: "dGVzdHNhbHQ=") + + do { + try await scheme.createKey(plainPin: testPin, hashedPin: hashedPin) + } catch { + throw XCTSkip("Secure Enclave key creation failed: \(error)") + } + + let dekFile = scheme.getDekFile(hashedPin: hashedPin) + + guard FileManager.default.fileExists(atPath: dekFile.path) else { + XCTFail("DEK file was not created") + return + } + + let resourceValues = try dekFile.resourceValues(forKeys: [.fileProtectionKey]) + let protection = resourceValues.fileProtection + + XCTAssertEqual(protection, .complete, "DEK file should have .complete file protection") + #endif + } + + func test_dekFile_parentDirectory_hasCompleteProtection() async throws { + #if targetEnvironment(simulator) + throw XCTSkip("File protection is not enforced on iOS Simulator; verify on a real device") + #else + let testPin = "1234" + let hashedPin = HashedPin(hash: "dGVzdGhhc2g=", salt: "dGVzdHNhbHQ=") + + do { + try await scheme.createKey(plainPin: testPin, hashedPin: hashedPin) + } catch { + throw XCTSkip("Secure Enclave key creation failed: \(error)") + } + + let dekFile = scheme.getDekFile(hashedPin: hashedPin) + let parentDir = dekFile.deletingLastPathComponent() + + let resourceValues = try parentDir.resourceValues(forKeys: [.fileProtectionKey]) + let protection = resourceValues.fileProtection + + XCTAssertEqual(protection, .complete, "DEK parent directory should have .complete file protection") + #endif + } +} diff --git a/SnapSafeTests/HardwareEncryptionSchemePinBindingTests.swift b/SnapSafeTests/HardwareEncryptionSchemePinBindingTests.swift new file mode 100644 index 0000000..98f46b3 --- /dev/null +++ b/SnapSafeTests/HardwareEncryptionSchemePinBindingTests.swift @@ -0,0 +1,105 @@ +// +// HardwareEncryptionSchemePinBindingTests.swift +// SnapSafeTests +// +// Created by Claude on 2026-05-31. +// +// C1 integration tests at the HardwareEncryptionScheme level: the DEK round +// trips only with the correct PIN, a wrong PIN is rejected, and legacy +// SE-only-wrapped DEKs migrate transparently. These exercise the real Secure +// Enclave, so they skip on the simulator (SE key creation is unavailable +// there) — run on a device. The keychain-free crypto boundary is covered +// exhaustively by PinDEKWrapperTests, which run on CI. + +import CryptoKit +import Foundation +import Mockable +import Security +import XCTest + +@testable import SnapSafe + +final class HardwareEncryptionSchemePinBindingTests: XCTestCase { + + private var deviceInfo: MockDeviceInfoDataSource! + private var scheme: HardwareEncryptionScheme! + private var testKeyAlias: String! + private let hashedPin = HashedPin(hash: "dGVzdGhhc2g=", salt: "dGVzdHNhbHQ=") + + override func setUp() async throws { + try await super.setUp() + deviceInfo = MockDeviceInfoDataSource() + given(deviceInfo).getDeviceIdentifier().willReturn(Data("test-device-id".utf8)) + // Isolated KEK alias: these Secure-Enclave tests run on a real device, where + // the keychain is shared with the app, so they must NEVER touch the production + // `snapsafe_kek`. + testKeyAlias = "snapsafe_kek_test_\(UUID().uuidString)" + scheme = HardwareEncryptionScheme(deviceInfo: deviceInfo, keyAlias: testKeyAlias) + } + + override func tearDown() async throws { + // Scoped cleanup of ONLY this test's isolated key + DEK file. Never the broad + // securityFailureReset() (which deletes ALL EC keys) — that would wipe the + // app's real pin_key/snapsafe_kek when run on a device. + if let scheme { await scheme.evictKey() } + if let testKeyAlias { Self.deleteHardwareKey(alias: testKeyAlias) } + if let scheme { try? FileManager.default.removeItem(at: scheme.getDekFile(hashedPin: hashedPin)) } + try await super.tearDown() + } + + /// Deletes a single hardware key by its application tag (NOT a bulk delete). + private static func deleteHardwareKey(alias: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: alias.data(using: .utf8)!, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom + ] + SecItemDelete(query as CFDictionary) + } + + func test_createThenDerive_withCorrectPin_recoversSameDEK() async throws { + try skipOnSimulator() + + try await scheme.createKey(plainPin: "1234", hashedPin: hashedPin) + let dek1 = try await scheme.deriveKey(plainPin: "1234", hashedPin: hashedPin) + let dek2 = try await scheme.deriveKey(plainPin: "1234", hashedPin: hashedPin) + + XCTAssertEqual(dek1.count, 32) + XCTAssertEqual(dek1, dek2, "Same PIN must deterministically recover the same DEK") + } + + func test_derive_withWrongPin_throwsWrongPin() async throws { + try skipOnSimulator() + + try await scheme.createKey(plainPin: "1234", hashedPin: hashedPin) + + do { + _ = try await scheme.deriveKey(plainPin: "9999", hashedPin: hashedPin) + XCTFail("Deriving with the wrong PIN should throw") + } catch let error as CryptoError { + XCTAssertEqual(error, .wrongPin) + } + } + + func test_storedPayload_isPinWrapped_notRawDEK() async throws { + try skipOnSimulator() + + try await scheme.createKey(plainPin: "1234", hashedPin: hashedPin) + + // SE-unwrap the on-disk file and confirm the payload is the PIN-wrapped + // form (nonce+ct+tag), not a bare 32-byte DEK. + let dekFile = scheme.getDekFile(hashedPin: hashedPin) + let onDisk = try Data(contentsOf: dekFile) + let payload = try scheme.decryptWithHardwareKeyForTesting(encrypted: onDisk) + + XCTAssertFalse(PinDEKWrapper.isLegacyRawDEK(payload), + "Newly created DEK must be stored PIN-wrapped, not as a raw DEK") + XCTAssertEqual(payload.count, PinDEKWrapper.wrappedSize) + } + + private func skipOnSimulator() throws { + #if targetEnvironment(simulator) + throw XCTSkip("Secure Enclave is unavailable on the simulator; run on a device") + #endif + } +} diff --git a/SnapSafeTests/HardwareEncryptionSchemeSecurityResetTests.swift b/SnapSafeTests/HardwareEncryptionSchemeSecurityResetTests.swift new file mode 100644 index 0000000..30a27a0 --- /dev/null +++ b/SnapSafeTests/HardwareEncryptionSchemeSecurityResetTests.swift @@ -0,0 +1,119 @@ +// +// HardwareEncryptionSchemeSecurityResetTests.swift +// SnapSafeTests +// +// Created by Claude on 2026-05-31. +// + +import Foundation +import Mockable +import Security +import XCTest + +@testable import SnapSafe + +final class HardwareEncryptionSchemeSecurityResetTests: XCTestCase { + private var deviceInfo: MockDeviceInfoDataSource! + private var scheme: HardwareEncryptionScheme! + + private static let kekAlias = "snapsafe_kek" + private static let pinAlias = "pin_key" + + override func setUp() async throws { + try await super.setUp() + // This suite exercises the BROAD securityFailureReset() / delete-all-EC-keys + // path, which cannot be isolated to test-only aliases on a shared keychain. + // Run it only on the simulator, whose keychain holds no real app keys; on a + // real device it would wipe the app's pin_key/snapsafe_kek. + #if !targetEnvironment(simulator) + throw XCTSkip("Destructive keychain-reset test runs on the simulator only (it would wipe a real device's app keychain).") + #endif + deviceInfo = MockDeviceInfoDataSource() + given(deviceInfo).getDeviceIdentifier().willReturn(Data("test-device-id".utf8)) + scheme = HardwareEncryptionScheme(deviceInfo: deviceInfo) + // Ensure clean keychain state for deterministic assertions + Self.deleteAllAppECHardwareKeys() + } + + override func tearDown() async throws { + Self.deleteAllAppECHardwareKeys() + try await super.tearDown() + } + + private static func deleteAllAppECHardwareKeys() { + // Belt-and-suspenders: NEVER bulk-delete EC keys on a real device — it would + // destroy the app's real keychain keys. Only the simulator keychain is + // disposable, and the suite is gated to the simulator anyway. + #if targetEnvironment(simulator) + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom + ] + SecItemDelete(query as CFDictionary) + #endif + } + + private static func hardwareKeyExists(alias: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: alias.data(using: .utf8)!, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true + ] + var item: CFTypeRef? + return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess + } + + /// H3 (a): `securityFailureReset` must delete the Secure Enclave / hardware key + /// material via `SecItemDelete`. Otherwise the keys survive across reset and + /// can decrypt any DEK that ever leaks (backup, extraction, etc.). + func test_securityFailureReset_deletesHardwareKeys() async throws { + // Force creation of both hardware keys the app uses. + do { + _ = try await scheme.encryptWithKeyAlias(plain: Data("payload".utf8), + keyAlias: Self.kekAlias) + _ = try await scheme.encryptWithKeyAlias(plain: Data("payload".utf8), + keyAlias: Self.pinAlias) + } catch { + throw XCTSkip("Hardware key creation unavailable in this environment: \(error)") + } + + XCTAssertTrue(Self.hardwareKeyExists(alias: Self.kekAlias), + "Precondition: KEK key should exist before reset") + XCTAssertTrue(Self.hardwareKeyExists(alias: Self.pinAlias), + "Precondition: pin_key should exist before reset") + + await scheme.securityFailureReset() + + XCTAssertFalse(Self.hardwareKeyExists(alias: Self.kekAlias), + "KEK hardware key must be deleted by securityFailureReset()") + XCTAssertFalse(Self.hardwareKeyExists(alias: Self.pinAlias), + "Auxiliary hardware keys (e.g. pin_key) must be deleted by securityFailureReset()") + } + + /// H3 (b): `evictKey` is fire-and-forget today, so the in-memory key may + /// outlive the reset. After `await securityFailureReset()`, any subsequent + /// `getDerivedKey()` must throw — proving the cache was evicted *before* + /// reset returned (not eventually). + func test_securityFailureReset_evictsCachedKeyBeforeReturning() async throws { + let hashedPin = HashedPin(hash: "dGVzdGhhc2g=", salt: "dGVzdHNhbHQ=") + + do { + try await scheme.createKey(plainPin: "1234", hashedPin: hashedPin) + try await scheme.deriveAndCacheKey(plainPin: "1234", hashedPin: hashedPin) + } catch { + throw XCTSkip("Hardware key derivation unavailable in this environment: \(error)") + } + + _ = try await scheme.getDerivedKey() // precondition: cache populated + + await scheme.securityFailureReset() + + do { + _ = try await scheme.getDerivedKey() + XCTFail("getDerivedKey should throw after securityFailureReset awaits eviction") + } catch CryptoError.keyNotDerived { + // expected + } + } +} diff --git a/SnapSafeTests/ImageProcessingTests.swift b/SnapSafeTests/ImageProcessingTests.swift new file mode 100644 index 0000000..57a2719 --- /dev/null +++ b/SnapSafeTests/ImageProcessingTests.swift @@ -0,0 +1,136 @@ +// +// ImageProcessingTests.swift +// SnapSafeTests +// + +import XCTest +import CoreLocation +import ImageIO +import UIKit +@testable import SnapSafe + +final class ImageProcessingTests: XCTestCase { + + private func solidImage(width: Int, height: Int) -> UIImage { + let size = CGSize(width: width, height: height) + UIGraphicsBeginImageContextWithOptions(size, true, 1) + UIColor.red.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let image = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return image + } + + func test_compressImageToJpeg_producesJpegMagicBytes() throws { + let data = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 16, height: 16), quality: 0.9) + ) + XCTAssertGreaterThan(data.count, 2) + XCTAssertEqual(Array(data.prefix(2)), [0xFF, 0xD8], "JPEG must start with the SOI marker") + } + + func test_resizeImage_producesRequestedSize() { + let resized = ImageProcessing.resizeImage( + solidImage(width: 100, height: 80), to: CGSize(width: 25, height: 20)) + XCTAssertEqual(resized.size, CGSize(width: 25, height: 20)) + } + + func test_rotateImage_ninetyDegrees_swapsDimensions() { + let rotated = ImageProcessing.rotateImage(solidImage(width: 40, height: 20), degrees: 90) + XCTAssertEqual(Int(rotated.size.width), 20) + XCTAssertEqual(Int(rotated.size.height), 40) + } + + func test_cgImageOrientation_mapsDegrees() { + XCTAssertEqual(ImageProcessing.cgImageOrientation(from: 0), .up) + XCTAssertEqual(ImageProcessing.cgImageOrientation(from: 90), .right) + XCTAssertEqual(ImageProcessing.cgImageOrientation(from: 180), .down) + XCTAssertEqual(ImageProcessing.cgImageOrientation(from: 270), .left) + XCTAssertEqual(ImageProcessing.cgImageOrientation(from: 45), .up) + } + + func test_extractEXIFMetadata_roundTripsOrientationWrittenByApplyMetadata() throws { + let jpeg = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 16, height: 16), quality: 0.9)) + let withMeta = ImageProcessing.applyImageMetadata( + jpeg, location: nil, applyRotation: false, rotationDegrees: 90) + let meta = ImageProcessing.extractEXIFMetadata(from: withMeta) + let orientation = try XCTUnwrap(meta[kCGImagePropertyOrientation as String] as? Int) + XCTAssertEqual(orientation, Int(CGImagePropertyOrientation.right.rawValue), "90° → .right") + } + + func test_processImageWithEXIFMetadata_invalidData_throws() { + XCTAssertThrowsError( + try ImageProcessing.processImageWithEXIFMetadata( + imageData: Data([0x00, 0x01]), preservedEXIFMetadata: [:], filename: "x")) + } + + func test_applyImageMetadata_embedsGpsWhenLocationProvided() throws { + let jpeg = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 16, height: 16), quality: 0.9)) + let location = CLLocation(latitude: 37.3349, longitude: -122.0090) + let withMeta = ImageProcessing.applyImageMetadata( + jpeg, location: location, applyRotation: true, rotationDegrees: 0) + let meta = ImageProcessing.extractEXIFMetadata(from: withMeta) + let gps = try XCTUnwrap(meta[kCGImagePropertyGPSDictionary as String] as? [String: Any]) + let latitude = try XCTUnwrap(gps[kCGImagePropertyGPSLatitude as String] as? Double) + XCTAssertEqual(latitude, 37.3349, accuracy: 0.0001) + let latitudeRef = try XCTUnwrap(gps[kCGImagePropertyGPSLatitudeRef as String] as? String) + XCTAssertEqual(latitudeRef, "N") + } + + func test_processImageWithEXIFMetadata_emptyMetadata_returnsValidJpeg() throws { + let jpeg = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 16, height: 16), quality: 0.9)) + let result = try ImageProcessing.processImageWithEXIFMetadata( + imageData: jpeg, preservedEXIFMetadata: [:], filename: "x") + XCTAssertEqual(Array(result.prefix(2)), [0xFF, 0xD8], + "empty-metadata fast path should still return valid JPEG data") + XCTAssertNotNil(UIImage(data: result)) + } + + func test_readImageMetadata_parsesDimensionsOrientationAndGps() throws { + let base = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 24, height: 16), quality: 0.9)) + let location = CLLocation(latitude: 37.3349, longitude: -122.0090) + let withMeta = ImageProcessing.applyImageMetadata( + base, location: location, applyRotation: false, rotationDegrees: 90) + let parsed = try XCTUnwrap(ImageProcessing.readImageMetadata(fromJPEGData: withMeta)) + XCTAssertEqual(parsed.width, 24) + XCTAssertEqual(parsed.height, 16) + XCTAssertEqual(parsed.orientation, .right, "90 degrees maps to TIFF orientation 6 (.right)") + let gps = try XCTUnwrap(parsed.gps) + XCTAssertEqual(gps.latitude, 37.3349, accuracy: 0.0001) + XCTAssertEqual(gps.longitude, -122.0090, accuracy: 0.0001) + } + + func test_createThumbnailData_reducesImageSize() throws { + // 80×80 image → scale=4 → 20×20 thumbnail + let original = try XCTUnwrap( + ImageProcessing.compressImageToJpeg(solidImage(width: 80, height: 80), quality: 0.9) + ) + let thumbData = try XCTUnwrap( + ImageProcessing.createThumbnailData(fromJPEGData: original, scale: 4), + "createThumbnailData should return non-nil for valid JPEG input" + ) + // Must still be valid JPEG + XCTAssertEqual(Array(thumbData.prefix(2)), [0xFF, 0xD8], + "thumbnail must be JPEG; got \(Array(thumbData.prefix(2)).map { String($0, radix: 16) })") + // Thumbnail must be substantially smaller than the original + XCTAssertLessThan(thumbData.count, original.count, + "thumbnail (\(thumbData.count) bytes) should be smaller than original (\(original.count) bytes)") + } + + func test_createThumbnailData_returnsNilForInvalidInput() { + let result = ImageProcessing.createThumbnailData(fromJPEGData: Data([0x00, 0x01, 0x02])) + XCTAssertNil(result, "createThumbnailData should return nil for non-JPEG data") + } + + func test_createThumbnailData_invalidScale_returnsNil() { + let jpeg = ImageProcessing.compressImageToJpeg(solidImage(width: 16, height: 16), quality: 0.9)! + XCTAssertNil(ImageProcessing.createThumbnailData(fromJPEGData: jpeg, scale: 0), + "scale of 0 should return nil") + XCTAssertNil(ImageProcessing.createThumbnailData(fromJPEGData: jpeg, scale: -1), + "negative scale should return nil") + } +} diff --git a/SnapSafeTests/LocationManagerTests.swift b/SnapSafeTests/LocationManagerTests.swift deleted file mode 100644 index 028dbd1..0000000 --- a/SnapSafeTests/LocationManagerTests.swift +++ /dev/null @@ -1,386 +0,0 @@ -// -// LocationManagerTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/25/25. -// - -import XCTest -import CoreLocation -import Combine -@testable import SnapSafe - -class LocationManagerTests: XCTestCase { - - private var locationManager: LocationManager! - private var cancellables: Set! - - override func setUp() { - super.setUp() - locationManager = LocationManager() - cancellables = Set() - - // Reset UserDefaults for testing - UserDefaults.standard.removeObject(forKey: "shouldIncludeLocationData") - } - - override func tearDown() { - // Clean up UserDefaults - UserDefaults.standard.removeObject(forKey: "shouldIncludeLocationData") - - cancellables?.removeAll() - cancellables = nil - locationManager = nil - super.tearDown() - } - - // MARK: - Initialization Tests - - /// Tests that LocationManager initializes with correct default values - /// Assertion: Should have proper initial state for authorization, location, and user preferences - func testInit_SetsCorrectDefaults() { - // Reset defaults and create new instance to test initialization - UserDefaults.standard.removeObject(forKey: "shouldIncludeLocationData") - let newLocationManager = LocationManager() - - XCTAssertEqual(newLocationManager.authorizationStatus, CLLocationManager().authorizationStatus, - "Authorization status should match system default") - XCTAssertNil(newLocationManager.lastLocation, "Last location should be nil initially") - XCTAssertFalse(newLocationManager.shouldIncludeLocationData, - "Should not include location data by default") - } - - /// Tests that LocationManager loads saved user preferences from UserDefaults - /// Assertion: Should restore shouldIncludeLocationData from saved preferences - func testInit_LoadsSavedPreferences() { - // Save preference and create new instance - UserDefaults.standard.set(true, forKey: "shouldIncludeLocationData") - let newLocationManager = LocationManager() - - XCTAssertTrue(newLocationManager.shouldIncludeLocationData, - "Should load saved preference for location data inclusion") - } - - // MARK: - Location Data Preference Tests - - /// Tests that setIncludeLocationData() updates both the property and UserDefaults - /// Assertion: Should persist preference and update published property synchronously - func testSetIncludeLocationData_UpdatesPropertyAndUserDefaults() { - let expectation = XCTestExpectation(description: "shouldIncludeLocationData should update") - - // Monitor property changes - locationManager.$shouldIncludeLocationData - .dropFirst() // Skip initial value - .sink { includeData in - XCTAssertTrue(includeData, "shouldIncludeLocationData should be updated to true") - expectation.fulfill() - } - .store(in: &cancellables) - - locationManager.setIncludeLocationData(true) - - // Assert UserDefaults is updated - XCTAssertTrue(UserDefaults.standard.bool(forKey: "shouldIncludeLocationData"), - "UserDefaults should be updated") - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests that setIncludeLocationData(false) properly disables location inclusion - /// Assertion: Should set preference to false and persist in UserDefaults - func testSetIncludeLocationData_DisablesLocationInclusion() { - // First enable, then disable - locationManager.setIncludeLocationData(true) - locationManager.setIncludeLocationData(false) - - XCTAssertFalse(locationManager.shouldIncludeLocationData, - "shouldIncludeLocationData should be false") - XCTAssertFalse(UserDefaults.standard.bool(forKey: "shouldIncludeLocationData"), - "UserDefaults should reflect disabled preference") - } - - // MARK: - Authorization Status Tests - - /// Tests that getAuthorizationStatusString() returns correct string representations - /// Assertion: Should provide user-friendly strings for all authorization status cases - func testGetAuthorizationStatusString_ReturnsCorrectStrings() { - let testCases: [(CLAuthorizationStatus, String)] = [ - (.notDetermined, "Not Determined"), - (.restricted, "Restricted"), - (.denied, "Denied"), - (.authorizedWhenInUse, "Authorized"), - (.authorizedAlways, "Authorized") - ] - - for (status, expectedString) in testCases { - locationManager.authorizationStatus = status - let statusString = locationManager.getAuthorizationStatusString() - XCTAssertEqual(statusString, expectedString, - "Status \(status) should return '\(expectedString)'") - } - } - - // MARK: - Location Metadata Tests - - /// Tests that getCurrentLocationMetadata() returns nil when location data is disabled - /// Assertion: Should not provide metadata when user has disabled location inclusion - func testGetCurrentLocationMetadata_ReturnsNilWhenDisabled() { - locationManager.setIncludeLocationData(false) - locationManager.authorizationStatus = .authorizedWhenInUse - locationManager.lastLocation = createTestLocation() - - let metadata = locationManager.getCurrentLocationMetadata() - - XCTAssertNil(metadata, "Should return nil when location data inclusion is disabled") - } - - /// Tests that getCurrentLocationMetadata() returns nil when not authorized - /// Assertion: Should not provide metadata without proper authorization - func testGetCurrentLocationMetadata_ReturnsNilWhenNotAuthorized() { - locationManager.setIncludeLocationData(true) - locationManager.authorizationStatus = .denied - locationManager.lastLocation = createTestLocation() - - let metadata = locationManager.getCurrentLocationMetadata() - - XCTAssertNil(metadata, "Should return nil when location access is not authorized") - } - - /// Tests that getCurrentLocationMetadata() returns nil when no location is available - /// Assertion: Should not provide metadata when lastLocation is nil - func testGetCurrentLocationMetadata_ReturnsNilWhenNoLocation() { - locationManager.setIncludeLocationData(true) - locationManager.authorizationStatus = .authorizedWhenInUse - locationManager.lastLocation = nil - - let metadata = locationManager.getCurrentLocationMetadata() - - XCTAssertNil(metadata, "Should return nil when no location is available") - } - - /// Tests that getCurrentLocationMetadata() returns proper GPS metadata when conditions are met - /// Assertion: Should create valid GPS metadata dictionary with latitude, longitude, and timestamp - func testGetCurrentLocationMetadata_ReturnsValidGPSMetadata() { - locationManager.setIncludeLocationData(true) - locationManager.authorizationStatus = .authorizedWhenInUse - - let testLocation = createTestLocation( - latitude: 37.7749, // San Francisco - longitude: -122.4194, - altitude: 100.0 - ) - locationManager.lastLocation = testLocation - - let metadata = locationManager.getCurrentLocationMetadata() - - XCTAssertNotNil(metadata, "Should return metadata when conditions are met") - - guard let gpsDict = metadata?[String(kCGImagePropertyGPSDictionary)] as? [String: Any] else { - XCTFail("Should contain GPS dictionary") - return - } - - // Test latitude - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLatitudeRef)] as? String, "N", - "Latitude reference should be North for positive latitude") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLatitude)] as? Double, 37.7749, - "Latitude should match test location") - - // Test longitude - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLongitudeRef)] as? String, "W", - "Longitude reference should be West for negative longitude") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLongitude)] as? Double, 122.4194, - "Longitude should be absolute value") - - // Test altitude - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSAltitudeRef)] as? Int, 0, - "Altitude reference should be 0 for above sea level") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSAltitude)] as? Double, 100.0, - "Altitude should match test location") - - // Test timestamp - XCTAssertNotNil(gpsDict[String(kCGImagePropertyGPSDateStamp)], - "Should include GPS timestamp") - } - - /// Tests that getCurrentLocationMetadata() handles negative coordinates correctly - /// Assertion: Should set proper hemisphere references for Southern/Western coordinates - func testGetCurrentLocationMetadata_HandlesNegativeCoordinates() { - locationManager.setIncludeLocationData(true) - locationManager.authorizationStatus = .authorizedWhenInUse - - let testLocation = createTestLocation( - latitude: -33.8688, // Sydney (Southern Hemisphere) - longitude: 151.2093, // Sydney (Eastern Hemisphere) - altitude: -10.0 // Below sea level - ) - locationManager.lastLocation = testLocation - - let metadata = locationManager.getCurrentLocationMetadata() - - guard let gpsDict = metadata?[String(kCGImagePropertyGPSDictionary)] as? [String: Any] else { - XCTFail("Should contain GPS dictionary") - return - } - - // Test negative latitude (Southern Hemisphere) - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLatitudeRef)] as? String, "S", - "Latitude reference should be South for negative latitude") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLatitude)] as? Double, 33.8688, - "Latitude should be absolute value") - - // Test positive longitude (Eastern Hemisphere) - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLongitudeRef)] as? String, "E", - "Longitude reference should be East for positive longitude") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSLongitude)] as? Double, 151.2093, - "Longitude should match test location") - - // Test negative altitude (below sea level) - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSAltitudeRef)] as? Int, 1, - "Altitude reference should be 1 for below sea level") - XCTAssertEqual(gpsDict[String(kCGImagePropertyGPSAltitude)] as? Double, 10.0, - "Altitude should be absolute value") - } - - /// Tests that getCurrentLocationMetadata() handles location with poor vertical accuracy - /// Assertion: Should exclude altitude data when vertical accuracy is poor - func testGetCurrentLocationMetadata_HandlesPoorVerticalAccuracy() { - locationManager.setIncludeLocationData(true) - locationManager.authorizationStatus = .authorizedWhenInUse - - let testLocation = createTestLocation( - latitude: 40.7128, - longitude: -74.0060, - altitude: 50.0, - verticalAccuracy: -1.0 // Negative indicates invalid reading - ) - locationManager.lastLocation = testLocation - - let metadata = locationManager.getCurrentLocationMetadata() - - guard let gpsDict = metadata?[String(kCGImagePropertyGPSDictionary)] as? [String: Any] else { - XCTFail("Should contain GPS dictionary") - return - } - - // Should not include altitude data when vertical accuracy is poor - XCTAssertNil(gpsDict[String(kCGImagePropertyGPSAltitudeRef)], - "Should not include altitude reference when vertical accuracy is poor") - XCTAssertNil(gpsDict[String(kCGImagePropertyGPSAltitude)], - "Should not include altitude when vertical accuracy is poor") - - // Should still include latitude and longitude - XCTAssertNotNil(gpsDict[String(kCGImagePropertyGPSLatitude)], - "Should still include latitude") - XCTAssertNotNil(gpsDict[String(kCGImagePropertyGPSLongitude)], - "Should still include longitude") - } - - // MARK: - Published Properties Tests - - /// Tests that authorizationStatus property publishes changes correctly - /// Assertion: Property changes should be observable by subscribers - func testAuthorizationStatus_PublishesChanges() { - let expectation = XCTestExpectation(description: "authorizationStatus should publish changes") - - locationManager.$authorizationStatus - .dropFirst() // Skip initial value - .sink { status in - XCTAssertEqual(status, .authorizedWhenInUse, "Should receive updated authorization status") - expectation.fulfill() - } - .store(in: &cancellables) - - locationManager.authorizationStatus = .authorizedWhenInUse - - wait(for: [expectation], timeout: 1.0) - } - - /// Tests that lastLocation property publishes changes correctly - /// Assertion: Location updates should be observable by subscribers -// func testLastLocation_PublishesChanges() { -// let expectation = XCTestExpectation(description: "lastLocation should publish changes") -// -// locationManager.$lastLocation -// .dropFirst() // Skip initial nil value -// .sink { location in -// XCTAssertNotNil(location, "Should receive updated location") -// XCTAssertEqual(location?.coordinate.latitude!, 37.7749, accuracy: 0.0001) -// expectation.fulfill() -// } -// .store(in: &cancellables) -// -// locationManager.lastLocation = createTestLocation() -// -// wait(for: [expectation], timeout: 1.0) -// } - - /// Tests that shouldIncludeLocationData property publishes changes correctly - /// Assertion: User preference changes should be observable by subscribers - func testShouldIncludeLocationData_PublishesChanges() { - let expectation = XCTestExpectation(description: "shouldIncludeLocationData should publish changes") - - locationManager.$shouldIncludeLocationData - .dropFirst() // Skip initial value - .sink { shouldInclude in - XCTAssertTrue(shouldInclude, "Should receive updated preference") - expectation.fulfill() - } - .store(in: &cancellables) - - locationManager.shouldIncludeLocationData = true - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Integration Tests - - /// Tests the complete flow of enabling location data and getting metadata - /// Assertion: Should properly handle the full workflow from permission to metadata generation - func testLocationDataFlow_CompleteWorkflow() { - // Start with disabled location data - XCTAssertFalse(locationManager.shouldIncludeLocationData, - "Should start with location data disabled") - - // Enable location data - locationManager.setIncludeLocationData(true) - XCTAssertTrue(locationManager.shouldIncludeLocationData, - "Should enable location data") - - // Set authorization as if user granted permission - locationManager.authorizationStatus = .authorizedWhenInUse - - // Simulate location update - locationManager.lastLocation = createTestLocation() - - // Get metadata - let metadata = locationManager.getCurrentLocationMetadata() - XCTAssertNotNil(metadata, "Should generate metadata with all conditions met") - - // Disable location data - locationManager.setIncludeLocationData(false) - - // Metadata should now be nil - let metadataAfterDisable = locationManager.getCurrentLocationMetadata() - XCTAssertNil(metadataAfterDisable, "Should not generate metadata when disabled") - } - - // MARK: - Helper Methods - - /// Creates a test CLLocation with specified coordinates - private func createTestLocation( - latitude: Double = 37.7749, - longitude: Double = -122.4194, - altitude: Double = 100.0, - horizontalAccuracy: Double = 5.0, - verticalAccuracy: Double = 5.0 - ) -> CLLocation { - return CLLocation( - coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - altitude: altitude, - horizontalAccuracy: horizontalAccuracy, - verticalAccuracy: verticalAccuracy, - timestamp: Date() - ) - } -} diff --git a/SnapSafeTests/LocationRepositoryTests.swift b/SnapSafeTests/LocationRepositoryTests.swift new file mode 100644 index 0000000..84abba1 --- /dev/null +++ b/SnapSafeTests/LocationRepositoryTests.swift @@ -0,0 +1,23 @@ +// +// LocationRepositoryTests.swift +// SnapSafeTests +// +// Maps CLAccuracyAuthorization — the user's iOS-level Precise/Approximate +// choice — to the read-only string shown in the Settings location section. +// + +import CoreLocation +import XCTest + +@testable import SnapSafe + +final class LocationRepositoryTests: XCTestCase { + + func test_fullAccuracy_mapsToPrecise() { + XCTAssertEqual(LocationRepository.accuracyDisplayString(for: .fullAccuracy), "Precise") + } + + func test_reducedAccuracy_mapsToApproximate() { + XCTAssertEqual(LocationRepository.accuracyDisplayString(for: .reducedAccuracy), "Approximate") + } +} diff --git a/SnapSafeTests/MinimumVisibilityGateTests.swift b/SnapSafeTests/MinimumVisibilityGateTests.swift new file mode 100644 index 0000000..29a850e --- /dev/null +++ b/SnapSafeTests/MinimumVisibilityGateTests.swift @@ -0,0 +1,167 @@ +// +// MinimumVisibilityGateTests.swift +// SnapSafeTests +// +// The saving HUD must never flash: once shown it stays visible for a +// minimum duration even if the underlying work (encrypt + save) finishes +// almost instantly. The gate is balanced (show/hide counting) because a new +// recording can stop while a previous clip is still encrypting — the HUD +// hides only when the last outstanding save completes. +// + +import XCTest + +@testable import SnapSafe + +/// Test double for the gate's sleep: records requested durations and suspends +/// until the test releases it, so dismissal timing is fully deterministic. +@MainActor +private final class ManualSleeper { + private(set) var requested: [TimeInterval] = [] + private var pending: [CheckedContinuation] = [] + private var prereleased = 0 + + func sleep(_ duration: TimeInterval) async { + requested.append(duration) + if prereleased > 0 { + prereleased -= 1 + return + } + await withCheckedContinuation { pending.append($0) } + } + + /// Lets the next (or current) sleeper proceed. Safe to call before the + /// sleep actually starts; the release is banked. + func release() { + if pending.isEmpty { + prereleased += 1 + } else { + pending.removeFirst().resume() + } + } +} + +@MainActor +final class MinimumVisibilityGateTests: XCTestCase { + + private var clock: TestClock! + private var sleeper: ManualSleeper! + private var gate: MinimumVisibilityGate! + + override func setUp() { + super.setUp() + clock = TestClock() + sleeper = ManualSleeper() + let sleeper = self.sleeper! + gate = MinimumVisibilityGate( + minimumDuration: 1.0, + clock: clock, + sleep: { await sleeper.sleep($0) } + ) + } + + func test_show_makesVisibleImmediately() { + XCTAssertFalse(gate.isVisible) + + gate.show() + + XCTAssertTrue(gate.isVisible) + } + + func test_hide_beforeMinimumDuration_staysVisibleForRemainder() async { + gate.show() + clock.advance(by: 0.3) + + let dismissal = gate.hide() + + // Still visible: only 0.3s of the 1.0s minimum has elapsed. + XCTAssertTrue(gate.isVisible) + XCTAssertNotNil(dismissal) + + sleeper.release() + await dismissal?.value + + XCTAssertFalse(gate.isVisible) + XCTAssertEqual(sleeper.requested.count, 1) + XCTAssertEqual(sleeper.requested[0], 0.7, accuracy: 0.0001) + } + + func test_hide_afterMinimumDuration_hidesImmediately() { + gate.show() + clock.advance(by: 1.5) + + let dismissal = gate.hide() + + XCTAssertFalse(gate.isVisible) + XCTAssertNil(dismissal) + XCTAssertTrue(sleeper.requested.isEmpty) + } + + func test_show_whileHidePending_cancelsDismissal() async { + gate.show() + clock.advance(by: 0.3) + let dismissal = gate.hide() + XCTAssertNotNil(dismissal) + + // A new save starts while the dismissal is counting down. + gate.show() + + sleeper.release() + await dismissal?.value + + // The canceled dismissal must not have hidden the HUD. + XCTAssertTrue(gate.isVisible) + + // The new save finishing after the minimum hides immediately. + clock.advance(by: 2.0) + let secondDismissal = gate.hide() + XCTAssertNil(secondDismissal) + XCTAssertFalse(gate.isVisible) + } + + func test_hide_withoutShow_isNoop() { + let dismissal = gate.hide() + + XCTAssertNil(dismissal) + XCTAssertFalse(gate.isVisible) + XCTAssertTrue(sleeper.requested.isEmpty) + } + + func test_overlappingSaves_hideOnlyWhenLastOneFinishes() { + gate.show() + gate.show() + clock.advance(by: 2.0) + + // First save finishing must not hide: the second is still active. + let firstDismissal = gate.hide() + XCTAssertNil(firstDismissal) + XCTAssertTrue(gate.isVisible) + + // Second (last) save finishing hides; minimum already satisfied. + let secondDismissal = gate.hide() + XCTAssertNil(secondDismissal) + XCTAssertFalse(gate.isVisible) + } + + func test_minimumDuration_countsFromFirstShow_whileContinuouslyVisible() async { + gate.show() + clock.advance(by: 0.4) + + // Second overlapping save; HUD has been visible continuously. + gate.show() + clock.advance(by: 0.4) + + gate.hide() + // 0.8s total visibility — last hide still owes 0.2s. + let dismissal = gate.hide() + XCTAssertTrue(gate.isVisible) + XCTAssertNotNil(dismissal) + + sleeper.release() + await dismissal?.value + + XCTAssertFalse(gate.isVisible) + XCTAssertEqual(sleeper.requested.count, 1) + XCTAssertEqual(sleeper.requested[0], 0.2, accuracy: 0.0001) + } +} diff --git a/SnapSafeTests/OrientationRotationTests.swift b/SnapSafeTests/OrientationRotationTests.swift new file mode 100644 index 0000000..49b3265 --- /dev/null +++ b/SnapSafeTests/OrientationRotationTests.swift @@ -0,0 +1,57 @@ +// +// OrientationRotationTests.swift +// SnapSafeTests +// + +import XCTest +import SwiftUI +import UIKit +@testable import SnapSafe + +final class OrientationRotationTests: XCTestCase { + + // MARK: - Utils.getRotationAngle(for:) + + func test_rotationAngle_portrait_isZero() { + XCTAssertEqual(Utils.getRotationAngle(for: .portrait), Angle(degrees: 0)) + } + + func test_rotationAngle_landscapeLeft_is90() { + XCTAssertEqual(Utils.getRotationAngle(for: .landscapeLeft), Angle(degrees: 90)) + } + + func test_rotationAngle_landscapeRight_isMinus90() { + XCTAssertEqual(Utils.getRotationAngle(for: .landscapeRight), Angle(degrees: -90)) + } + + func test_rotationAngle_portraitUpsideDown_is180() { + XCTAssertEqual(Utils.getRotationAngle(for: .portraitUpsideDown), Angle(degrees: 180)) + } + + func test_rotationAngle_faceUp_defaultsToZero() { + XCTAssertEqual(Utils.getRotationAngle(for: .faceUp), Angle(degrees: 0)) + } + + // MARK: - OrientationObserver.resolve(incoming:last:) + + func test_resolve_validOrientation_returnsIncoming() { + XCTAssertEqual( + OrientationObserver.resolve(incoming: .landscapeLeft, last: .portrait), + .landscapeLeft + ) + } + + func test_resolve_faceUp_keepsLast() { + XCTAssertEqual( + OrientationObserver.resolve(incoming: .faceUp, last: .landscapeRight), + .landscapeRight + ) + } + + func test_resolve_unknown_keepsLast() { + XCTAssertEqual( + OrientationObserver.resolve(incoming: .unknown, last: .portrait), + .portrait + ) + } +} diff --git a/SnapSafeTests/PINManagerTests.swift b/SnapSafeTests/PINManagerTests.swift deleted file mode 100644 index 100cfc6..0000000 --- a/SnapSafeTests/PINManagerTests.swift +++ /dev/null @@ -1,533 +0,0 @@ -// -// PINManagerTests.swift -// SnapSafeTests -// -// Created by Claude on 5/25/25. -// - -import XCTest -import Combine -@testable import SnapSafe - -/// Comprehensive test suite for PINManager -/// -/// This test suite demonstrates various iOS testing patterns: -/// - Unit testing with XCTest -/// - Testing published properties with Combine -/// - Testing UserDefaults interactions -/// - Async testing with expectations -/// - Mock data and test isolation -class PINManagerTests: XCTestCase { - - // MARK: - Test Properties - - /// Reference to the PINManager instance under test - var pinManager: PINManager! - - /// Test UserDefaults to isolate tests from real app data - var testUserDefaults: UserDefaults! - - /// Combine subscriptions for testing published properties - var cancellables: Set = [] - - // MARK: - Test Lifecycle - - /// Set up method called before each test method - /// This ensures each test starts with a clean state - override func setUp() { - super.setUp() - - // Create a test-specific UserDefaults suite to avoid affecting real app data - let suiteName = "PINManagerTests-\(UUID().uuidString)" - testUserDefaults = UserDefaults(suiteName: suiteName)! - - // Clear any existing data in test defaults - testUserDefaults.removePersistentDomain(forName: suiteName) - - // Note: We can't easily inject UserDefaults into PINManager due to singleton pattern - // In a production app, we would refactor PINManager to accept UserDefaults as dependency - pinManager = PINManager.shared - - // Clear any existing PIN state for testing and wait for async completion - clearPINAndWait() - - // Reset requirePINOnResume to default value and wait for async completion - resetRequirePINOnResumeAndWait() - - // Clear subscriptions - cancellables.removeAll() - - print("Test setup completed - clean state established") - } - - /// Helper method to clear PIN and wait for async update to complete - private func clearPINAndWait() { - let expectation = expectation(description: "PIN should be cleared") - - // If PIN is already not set, we're done - if !pinManager.isPINSet { - expectation.fulfill() - } else { - // Subscribe to changes and wait for isPINSet to become false - pinManager.$isPINSet - .dropFirst() - .sink { isPINSet in - if !isPINSet { - expectation.fulfill() - } - } - .store(in: &cancellables) - } - - // Clear the PIN - pinManager.clearPIN() - - // Wait for async update - wait(for: [expectation], timeout: 1.0) - - // Clear subscriptions after setup - cancellables.removeAll() - } - - /// Helper method to reset requirePINOnResume to default and wait for async update - private func resetRequirePINOnResumeAndWait() { - let expectation = expectation(description: "requirePINOnResume should be reset to true") - - // If already true, we're done - if pinManager.requirePINOnResume { - expectation.fulfill() - } else { - // Subscribe to changes and wait for requirePINOnResume to become true - pinManager.$requirePINOnResume - .dropFirst() - .sink { requirePIN in - if requirePIN { - expectation.fulfill() - } - } - .store(in: &cancellables) - } - - // Reset to default value - pinManager.setRequirePINOnResume(true) - - // Wait for async update - wait(for: [expectation], timeout: 1.0) - - // Clear subscriptions after setup - cancellables.removeAll() - } - - /// Tear down method called after each test method - override func tearDown() { - // Clean up subscriptions - cancellables.removeAll() - - // Clear PIN state using our helper method to ensure async completion - clearPINAndWait() - - // Reset requirePINOnResume to default value - resetRequirePINOnResumeAndWait() - - // Clear any UserDefaults keys that might have been set - UserDefaults.standard.removeObject(forKey: "snapSafe.userPIN") - UserDefaults.standard.removeObject(forKey: "snapSafe.isPINSet") - UserDefaults.standard.removeObject(forKey: "snapSafe.requirePINOnResume") - - pinManager = nil - testUserDefaults = nil - - super.tearDown() - print("Test teardown completed") - } - - // MARK: - PIN Setting Tests - - /// Test that setting a PIN updates the isPINSet property - func testSetPIN_UpdatesIsPINSetProperty() { - // Given: Initial state should be false - XCTAssertFalse(pinManager.isPINSet, "PIN should not be set initially") - - // When: Setting a PIN - let testPIN = "1234" - pinManager.setPIN(testPIN) - - // Then: Wait for async update and verify using the helper method - waitForPINSetUpdate(expectedValue: true) - - XCTAssertTrue(pinManager.isPINSet, "PIN should be marked as set after setPIN is called") - } - - /// Test PIN setting with various valid PIN formats - func testSetPIN_WithVariousPINFormats() { - let testPINs = ["1234", "0000", "9876", "1111"] - - for testPIN in testPINs { - // When: Setting each PIN - pinManager.setPIN(testPIN) - - // Wait for async update - waitForPINSetUpdate(expectedValue: true) - - // Then: Should be marked as set and verifiable - XCTAssertTrue(pinManager.isPINSet, "PIN \(testPIN) should be marked as set") - XCTAssertTrue(pinManager.verifyPIN(testPIN), "PIN \(testPIN) should verify correctly") - - // Clean up for next iteration - pinManager.clearPIN() - waitForPINSetUpdate(expectedValue: false) - } - } - - /// Test that setting a PIN publishes changes to observers - func testSetPIN_PublishesChangesToObservers() { - // Given: Expectation for published property change (only expect one fulfillment) - let expectation = expectation(description: "isPINSet should be published") - expectation.expectedFulfillmentCount = 1 - - var receivedValues: [Bool] = [] - var hasFulfilled = false - - // Subscribe to isPINSet changes, skipping the initial value - pinManager.$isPINSet - .dropFirst() // Skip the initial subscription value - .sink { isPINSet in - receivedValues.append(isPINSet) - if isPINSet && !hasFulfilled { - hasFulfilled = true - expectation.fulfill() - } - } - .store(in: &cancellables) - - // When: Setting a PIN - pinManager.setPIN("1234") - - // Then: Should receive published change - waitForExpectations(timeout: 1.0) { error in - XCTAssertNil(error, "Should not timeout waiting for published change") - } - - XCTAssertTrue(receivedValues.contains(true), "Should have received isPINSet = true") - } - - // MARK: - PIN Verification Tests - - /// Test PIN verification with correct PIN - func testVerifyPIN_WithCorrectPIN_ReturnsTrue() { - // Given: A PIN is set - let testPIN = "1234" - pinManager.setPIN(testPIN) - - // When: Verifying with correct PIN - let result = pinManager.verifyPIN(testPIN) - - // Then: Should return true - XCTAssertTrue(result, "Should return true when verifying correct PIN") - } - - /// Test PIN verification with incorrect PIN - func testVerifyPIN_WithIncorrectPIN_ReturnsFalse() { - // Given: A PIN is set - pinManager.setPIN("1234") - - // When: Verifying with incorrect PIN - let result = pinManager.verifyPIN("5678") - - // Then: Should return false - XCTAssertFalse(result, "Should return false when verifying incorrect PIN") - } - - /// Test PIN verification when no PIN is set - func testVerifyPIN_WhenNoPINSet_ReturnsFalse() { - // Given: No PIN is set (initial state) - XCTAssertFalse(pinManager.isPINSet, "No PIN should be set initially") - - // When: Attempting to verify any PIN - let result = pinManager.verifyPIN("1234") - - // Then: Should return false - XCTAssertFalse(result, "Should return false when no PIN is set") - } - - /// Test PIN verification with edge cases - func testVerifyPIN_EdgeCases() { - // Test empty PIN - pinManager.setPIN("") - XCTAssertTrue(pinManager.verifyPIN(""), "Empty PIN should verify correctly") - XCTAssertFalse(pinManager.verifyPIN("1234"), "Non-empty PIN should not match empty stored PIN") - - // Test PIN with spaces - pinManager.setPIN(" 123 ") - XCTAssertTrue(pinManager.verifyPIN(" 123 "), "PIN with spaces should verify correctly") - XCTAssertFalse(pinManager.verifyPIN("123"), "PIN without spaces should not match PIN with spaces") - } - - // MARK: - PIN Clearing Tests - - /// Test that clearing PIN resets the state - func testClearPIN_ResetsState() { - // Given: A PIN is set - pinManager.setPIN("1234") - waitForPINSetUpdate(expectedValue: true) - XCTAssertTrue(pinManager.isPINSet, "PIN should be set initially") - - // When: Clearing the PIN - pinManager.clearPIN() - waitForPINSetUpdate(expectedValue: false) - - // Then: State should be reset - XCTAssertFalse(pinManager.isPINSet, "PIN should not be set after clearing") - XCTAssertFalse(pinManager.verifyPIN("1234"), "Old PIN should not verify after clearing") - } - - /// Test that clearing PIN publishes changes - func testClearPIN_PublishesChanges() { - // Given: A PIN is set - pinManager.setPIN("1234") - waitForPINSetUpdate(expectedValue: true) - - let expectation = expectation(description: "isPINSet should be published as false") - var finalValue: Bool? - - // Subscribe to changes AFTER the PIN is set, so dropFirst skips the current true value - pinManager.$isPINSet - .dropFirst() // Skip the current true value - .sink { isPINSet in - finalValue = isPINSet - if !isPINSet { // Only fulfill when we get false - expectation.fulfill() - } - } - .store(in: &cancellables) - - // When: Clearing the PIN - pinManager.clearPIN() - - // Then: Should publish false - waitForExpectations(timeout: 1.0) { error in - XCTAssertNil(error, "Should not timeout waiting for published change") - } - - XCTAssertEqual(finalValue, false, "Should have published isPINSet = false") - } - - // MARK: - PIN Resume Requirement Tests - - /// Test setting requirePINOnResume flag - func testSetRequirePINOnResume_UpdatesProperty() { - // Given: Initial state (should be true by default) - XCTAssertTrue(pinManager.requirePINOnResume, "Should require PIN on resume by default") - - // When: Setting to false - pinManager.setRequirePINOnResume(false) - waitForRequirePINOnResumeUpdate(expectedValue: false) - - // Then: Should be updated - XCTAssertFalse(pinManager.requirePINOnResume, "Should not require PIN on resume after setting to false") - - // When: Setting back to true - pinManager.setRequirePINOnResume(true) - waitForRequirePINOnResumeUpdate(expectedValue: true) - - // Then: Should be updated again - XCTAssertTrue(pinManager.requirePINOnResume, "Should require PIN on resume after setting to true") - } - - /// Test that requirePINOnResume publishes changes - func testSetRequirePINOnResume_PublishesChanges() { - // Given: Ensure we start with a known stable state (true) - XCTAssertTrue(pinManager.requirePINOnResume, "Should start with requirePINOnResume = true") - - let expectation = expectation(description: "requirePINOnResume should be published") - var receivedValue: Bool? - - // Subscribe to requirePINOnResume changes AFTER confirming stable state - pinManager.$requirePINOnResume - .dropFirst() // Skip the current true value - .sink { requirePIN in - receivedValue = requirePIN - if !requirePIN { // Only fulfill when we get false - expectation.fulfill() - } - } - .store(in: &cancellables) - - // When: Changing the setting from true to false - pinManager.setRequirePINOnResume(false) - - // Then: Should receive published change - waitForExpectations(timeout: 1.0) { error in - XCTAssertNil(error, "Should not timeout waiting for published change") - } - - XCTAssertEqual(receivedValue, false, "Should have received requirePINOnResume = false") - } - - // MARK: - Last Active Time Tests - - /// Test updating last active time - func testUpdateLastActiveTime_UpdatesProperty() { - // Given: Initial last active time - let initialTime = pinManager.lastActiveTime - - // Wait a small amount to ensure time difference - let expectation = expectation(description: "Wait for time to pass") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - expectation.fulfill() - } - waitForExpectations(timeout: 0.1) - - // When: Updating last active time - pinManager.updateLastActiveTime() - - // Then: Should be updated to a more recent time - XCTAssertGreaterThan(pinManager.lastActiveTime, initialTime, "Last active time should be updated to a more recent time") - } - - // MARK: - Integration Tests - - /// Test complete PIN lifecycle: set → verify → clear → verify - func testCompletePINLifecycle() { - let testPIN = "1234" - - // Initially no PIN - XCTAssertFalse(pinManager.isPINSet) - XCTAssertFalse(pinManager.verifyPIN(testPIN)) - - // Set PIN - pinManager.setPIN(testPIN) - waitForPINSetUpdate(expectedValue: true) - XCTAssertTrue(pinManager.isPINSet) - XCTAssertTrue(pinManager.verifyPIN(testPIN)) - XCTAssertFalse(pinManager.verifyPIN("9999")) - - // Clear PIN - pinManager.clearPIN() - waitForPINSetUpdate(expectedValue: false) - XCTAssertFalse(pinManager.isPINSet) - XCTAssertFalse(pinManager.verifyPIN(testPIN)) - } - - /// Test multiple PIN changes - func testMultiplePINChanges() { - let pins = ["1111", "2222", "3333"] - - for (index, pin) in pins.enumerated() { - // Set new PIN - pinManager.setPIN(pin) - waitForPINSetUpdate(expectedValue: true) - - // Verify current PIN works - XCTAssertTrue(pinManager.verifyPIN(pin), "PIN \(pin) should verify correctly") - - // Verify previous PINs don't work - for previousIndex in 0..( - on publisher: Published.Publisher, - expectedValue: T, - timeout: TimeInterval = 1.0, - file: StaticString = #file, - line: UInt = #line - ) { - let expectation = expectation(description: "Wait for published value change") - - publisher - .first { $0 == expectedValue } - .sink { _ in - expectation.fulfill() - } - .store(in: &cancellables) - - waitForExpectations(timeout: timeout) { error in - if let error = error { - XCTFail("Timeout waiting for published value \(expectedValue): \(error)", file: file, line: line) - } - } - } -} diff --git a/SnapSafeTests/PhotoDetailViewModelTests.swift b/SnapSafeTests/PhotoDetailViewModelTests.swift deleted file mode 100644 index 7b123a9..0000000 --- a/SnapSafeTests/PhotoDetailViewModelTests.swift +++ /dev/null @@ -1,496 +0,0 @@ -// -// PhotoDetailViewModelTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/25/25. -// - -import XCTest -import UIKit -import Combine -@testable import SnapSafe - -class PhotoDetailViewModelTests: XCTestCase { - - private var viewModel: PhotoDetailViewModel! - private var testPhotos: [SecurePhoto]! - private var cancellables: Set! - - override func setUp() { - super.setUp() - testPhotos = createTestPhotos() - cancellables = Set() - } - - override func tearDown() { - cancellables?.removeAll() - cancellables = nil - viewModel = nil - testPhotos = nil - super.tearDown() - } - - // MARK: - Initialization Tests - - /// Tests that PhotoDetailViewModel initializes correctly with a single photo - /// Assertion: Should set up single photo mode with correct initial state - func testInit_WithSinglePhoto_SetsCorrectState() { - let singlePhoto = testPhotos[0] - var deleteCallbackCalled = false - var dismissCallbackCalled = false - - viewModel = PhotoDetailViewModel( - photo: singlePhoto, - showFaceDetection: true, - onDelete: { _ in deleteCallbackCalled = true }, - onDismiss: { dismissCallbackCalled = true } - ) - - XCTAssertTrue(viewModel.showFaceDetection, "Face detection should be enabled") - XCTAssertEqual(viewModel.currentPhoto.id, singlePhoto.id, "Current photo should match provided photo") - XCTAssertTrue(viewModel.allPhotos.isEmpty, "All photos array should be empty in single photo mode") - XCTAssertEqual(viewModel.currentIndex, 0, "Current index should be 0") - XCTAssertFalse(viewModel.canGoToPrevious, "Should not be able to go to previous in single photo mode") - XCTAssertFalse(viewModel.canGoToNext, "Should not be able to go to next in single photo mode") - } - - /// Tests that PhotoDetailViewModel initializes correctly with multiple photos - /// Assertion: Should set up multi-photo mode with correct initial state and navigation capabilities - func testInit_WithMultiplePhotos_SetsCorrectState() { - let initialIndex = 1 - var deleteCallbackCalled = false - var dismissCallbackCalled = false - - viewModel = PhotoDetailViewModel( - allPhotos: testPhotos, - initialIndex: initialIndex, - showFaceDetection: false, - onDelete: { _ in deleteCallbackCalled = true }, - onDismiss: { dismissCallbackCalled = true } - ) - - XCTAssertFalse(viewModel.showFaceDetection, "Face detection should be disabled") - XCTAssertEqual(viewModel.allPhotos.count, testPhotos.count, "All photos should be set correctly") - XCTAssertEqual(viewModel.currentIndex, initialIndex, "Current index should match initial index") - XCTAssertEqual(viewModel.currentPhoto.id, testPhotos[initialIndex].id, "Current photo should match photo at initial index") - XCTAssertTrue(viewModel.canGoToPrevious, "Should be able to go to previous from index 1") - XCTAssertTrue(viewModel.canGoToNext, "Should be able to go to next from index 1") - } - - // MARK: - Navigation Tests - - /// Tests that navigation to previous photo works correctly - /// Assertion: Should update current index and reset UI state when navigating to previous photo - func testNavigateToPrevious_UpdatesStateCorrectly() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 2, showFaceDetection: true) - - let expectation = XCTestExpectation(description: "Navigation should update current index") - - viewModel.$currentIndex - .dropFirst() - .sink { index in - XCTAssertEqual(index, 1, "Current index should be decremented") - expectation.fulfill() - } - .store(in: &cancellables) - - // Set some UI state that should be reset - viewModel.imageRotation = 90 - viewModel.currentScale = 2.0 - viewModel.isFaceDetectionActive = true - - viewModel.navigateToPrevious() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(viewModel.imageRotation, 0, "Image rotation should be reset") - XCTAssertEqual(viewModel.currentScale, 1.0, "Scale should be reset") - XCTAssertFalse(viewModel.isFaceDetectionActive, "Face detection should be deactivated") - XCTAssertTrue(viewModel.detectedFaces.isEmpty, "Detected faces should be cleared") - XCTAssertNil(viewModel.modifiedImage, "Modified image should be cleared") - } - - /// Tests that navigation to next photo works correctly - /// Assertion: Should update current index and reset UI state when navigating to next photo - func testNavigateToNext_UpdatesStateCorrectly() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - let expectation = XCTestExpectation(description: "Navigation should update current index") - - viewModel.$currentIndex - .dropFirst() - .sink { index in - XCTAssertEqual(index, 1, "Current index should be incremented") - expectation.fulfill() - } - .store(in: &cancellables) - - // Set some UI state that should be reset - viewModel.imageRotation = 180 - viewModel.dragOffset = CGSize(width: 50, height: 50) - viewModel.detectedFaces = [DetectedFace(bounds: CGRect(x: 0, y: 0, width: 50, height: 50))] - - viewModel.navigateToNext() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertEqual(viewModel.imageRotation, 0, "Image rotation should be reset") - XCTAssertEqual(viewModel.dragOffset, .zero, "Drag offset should be reset") - XCTAssertTrue(viewModel.detectedFaces.isEmpty, "Detected faces should be cleared") - } - - /// Tests that navigation respects boundaries - /// Assertion: Should not navigate beyond array bounds - func testNavigation_RespectsBoundaries() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - // At index 0, can't go to previous - XCTAssertFalse(viewModel.canGoToPrevious, "Should not be able to go to previous at index 0") - viewModel.navigateToPrevious() - XCTAssertEqual(viewModel.currentIndex, 0, "Index should remain 0 when trying to go to previous") - - // Move to last index - viewModel.currentIndex = testPhotos.count - 1 - - // At last index, can't go to next - XCTAssertFalse(viewModel.canGoToNext, "Should not be able to go to next at last index") - viewModel.navigateToNext() - XCTAssertEqual(viewModel.currentIndex, testPhotos.count - 1, "Index should remain at last position") - } - - // MARK: - Zoom and Pan Tests - - /// Tests that zoom and pan can be reset correctly - /// Assertion: Should reset all zoom and pan related properties to default values - func testResetZoomAndPan_ResetsAllProperties() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - let expectation = XCTestExpectation(description: "Zoom and pan should reset") - expectation.expectedFulfillmentCount = 4 - - // Set non-default values - viewModel.currentScale = 3.0 - viewModel.dragOffset = CGSize(width: 100, height: 100) - viewModel.lastScale = 3.0 - viewModel.isZoomed = true - viewModel.lastDragPosition = CGSize(width: 50, height: 50) - - // Monitor changes - viewModel.$currentScale.dropFirst().sink { scale in - XCTAssertEqual(scale, 1.0, "Current scale should reset to 1.0") - expectation.fulfill() - }.store(in: &cancellables) - - viewModel.$dragOffset.dropFirst().sink { offset in - XCTAssertEqual(offset, .zero, "Drag offset should reset to zero") - expectation.fulfill() - }.store(in: &cancellables) - - viewModel.$lastScale.dropFirst().sink { scale in - XCTAssertEqual(scale, 1.0, "Last scale should reset to 1.0") - expectation.fulfill() - }.store(in: &cancellables) - - viewModel.$isZoomed.dropFirst().sink { isZoomed in - XCTAssertFalse(isZoomed, "Is zoomed should reset to false") - expectation.fulfill() - }.store(in: &cancellables) - - viewModel.resetZoomAndPan() - - wait(for: [expectation], timeout: 2.0) - - XCTAssertEqual(viewModel.lastDragPosition, .zero, "Last drag position should reset to zero") - } - - // MARK: - Image Rotation Tests - - /// Tests that image rotation works correctly - /// Assertion: Should update rotation angle and reset zoom/pan when rotating - func testRotateImage_UpdatesRotationAndResetsZoom() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - // Set some zoom state - viewModel.currentScale = 2.0 - viewModel.dragOffset = CGSize(width: 50, height: 50) - - viewModel.rotateImage(direction: 90) - - XCTAssertEqual(viewModel.imageRotation, 90, "Image should be rotated 90 degrees") - XCTAssertEqual(viewModel.currentScale, 1.0, "Scale should be reset when rotating") - XCTAssertEqual(viewModel.dragOffset, .zero, "Drag offset should be reset when rotating") - } - - /// Tests that image rotation normalizes angles correctly - /// Assertion: Should keep rotation within 0-360 degree range - func testRotateImage_NormalizesAngles() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - // Rotate multiple times to test normalization - viewModel.rotateImage(direction: 90) - viewModel.rotateImage(direction: 90) - viewModel.rotateImage(direction: 90) - viewModel.rotateImage(direction: 90) - - XCTAssertEqual(viewModel.imageRotation, 0, "Rotation should normalize to 0 after 360 degrees") - - // Test negative rotation - viewModel.rotateImage(direction: -90) - XCTAssertEqual(viewModel.imageRotation, 270, "Negative rotation should normalize correctly") - } - - // MARK: - Face Detection Tests - - /// Tests that face detection can be activated and processes correctly - /// Assertion: Should update face detection state and trigger face detection process - func testDetectFaces_ActivatesAndProcesses() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - let expectation = XCTestExpectation(description: "Face detection should activate") - - viewModel.$isFaceDetectionActive - .dropFirst() - .sink { isActive in - XCTAssertTrue(isActive, "Face detection should be activated") - expectation.fulfill() - } - .store(in: &cancellables) - - viewModel.detectFaces() - - wait(for: [expectation], timeout: 1.0) - - XCTAssertTrue(viewModel.processingFaces, "Should be processing faces initially") - XCTAssertTrue(viewModel.detectedFaces.isEmpty, "Detected faces should be empty initially") - XCTAssertNil(viewModel.modifiedImage, "Modified image should be nil initially") - } - - /// Tests that face selection toggle works correctly - /// Assertion: Should toggle face selection state correctly - func testToggleFaceSelection_WorksCorrectly() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - let testFace = DetectedFace(bounds: CGRect(x: 10, y: 10, width: 50, height: 50), isSelected: false) - viewModel.detectedFaces = [testFace] - - XCTAssertFalse(testFace.isSelected, "Face should initially be unselected") - XCTAssertFalse(viewModel.hasFacesSelected, "Should not have faces selected initially") - - viewModel.toggleFaceSelection(testFace) - - XCTAssertTrue(viewModel.detectedFaces[0].isSelected, "Face should be selected after toggle") - XCTAssertTrue(viewModel.hasFacesSelected, "Should have faces selected after toggle") - - viewModel.toggleFaceSelection(testFace) - - XCTAssertFalse(viewModel.detectedFaces[0].isSelected, "Face should be unselected after second toggle") - XCTAssertFalse(viewModel.hasFacesSelected, "Should not have faces selected after second toggle") - } - - /// Tests that mask mode selection affects UI text correctly - /// Assertion: Should update action titles and button labels based on selected mask mode - func testMaskModeSelection_UpdatesUIText() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - let maskModes: [(MaskMode, String, String, String)] = [ - (.blur, "Blur Selected Faces", "blur", "Blur Faces"), - (.pixelate, "Pixelate Selected Faces", "pixelate", "Pixelate Faces"), - (.blackout, "Blackout Selected Faces", "blackout", "Blackout Faces"), - (.noise, "Apply Noise to Selected Faces", "apply noise to", "Apply Noise") - ] - - for (mode, expectedTitle, expectedVerb, expectedButton) in maskModes { - viewModel.selectedMaskMode = mode - - XCTAssertEqual(viewModel.maskActionTitle, expectedTitle, "Action title should match for \(mode)") - XCTAssertEqual(viewModel.maskActionVerb, expectedVerb, "Action verb should match for \(mode)") - XCTAssertEqual(viewModel.maskButtonLabel, expectedButton, "Button label should match for \(mode)") - } - } - - // MARK: - Photo Management Tests - - /// Tests that photo deletion works correctly for single photo - /// Assertion: Should trigger onDelete and onDismiss callbacks for single photo - func testDeletePhoto_SinglePhoto_TriggersCallbacks() { - let singlePhoto = testPhotos[0] - var deletedPhoto: SecurePhoto? - var dismissCalled = false - - viewModel = PhotoDetailViewModel( - photo: singlePhoto, - showFaceDetection: false, - onDelete: { photo in deletedPhoto = photo }, - onDismiss: { dismissCalled = true } - ) - - let expectation = XCTestExpectation(description: "Delete callbacks should be triggered") - expectation.expectedFulfillmentCount = 2 - - // Monitor for callback execution - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if deletedPhoto != nil { expectation.fulfill() } - if dismissCalled { expectation.fulfill() } - } - - viewModel.deletePhoto() - - wait(for: [expectation], timeout: 2.0) - - XCTAssertNotNil(deletedPhoto, "onDelete callback should be called") - XCTAssertEqual(deletedPhoto?.id, singlePhoto.id, "Correct photo should be passed to onDelete") - } - - /// Tests that photo deletion works correctly for multiple photos - /// Assertion: Should update photo array and navigation state correctly - func testDeletePhoto_MultiplePhotos_UpdatesArray() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 1, showFaceDetection: false) - let initialCount = viewModel.allPhotos.count - let photoToDelete = viewModel.currentPhoto - - let expectation = XCTestExpectation(description: "Photo array should be updated") - - viewModel.$allPhotos - .dropFirst() - .sink { photos in - XCTAssertEqual(photos.count, initialCount - 1, "Photo count should decrease by 1") - XCTAssertFalse(photos.contains { $0.id == photoToDelete.id }, "Deleted photo should not be in array") - expectation.fulfill() - } - .store(in: &cancellables) - - viewModel.deletePhoto() - - wait(for: [expectation], timeout: 2.0) - } - - // MARK: - Display Image Tests - - /// Tests that displayedImage returns correct image based on face detection state - /// Assertion: Should return modified image when face detection is active, otherwise full image - func testDisplayedImage_ReturnsCorrectImage() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - // Initially should return full image - let initialImage = viewModel.displayedImage - XCTAssertNotNil(initialImage, "Should return a valid image") - - // Set modified image and activate face detection - let modifiedImage = createTestImage(size: CGSize(width: 100, height: 100)) - viewModel.modifiedImage = modifiedImage - viewModel.isFaceDetectionActive = true - - let displayedWithModified = viewModel.displayedImage - // Note: We can't directly compare UIImage objects, so we check that it's not nil - XCTAssertNotNil(displayedWithModified, "Should return modified image when face detection is active") - } - - // MARK: - Memory Management Tests - - /// Tests that preloadAdjacentPhotos manages memory correctly - /// Assertion: Should mark adjacent photos as visible for memory management - func testPreloadAdjacentPhotos_ManagesMemoryCorrectly() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 1, showFaceDetection: false) - - // Initially photos should not be marked as visible - XCTAssertFalse(testPhotos[0].isVisible, "Previous photo should not be visible initially") - XCTAssertFalse(testPhotos[2].isVisible, "Next photo should not be visible initially") - - viewModel.preloadAdjacentPhotos() - - // After preloading, adjacent photos should be marked as visible - XCTAssertTrue(testPhotos[0].isVisible, "Previous photo should be marked as visible") - XCTAssertTrue(testPhotos[2].isVisible, "Next photo should be marked as visible") - } - - /// Tests that onAppear properly sets up memory management - /// Assertion: Should mark current photo as visible and register with memory manager - func testOnAppear_SetsUpMemoryManagement() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - XCTAssertFalse(testPhotos[0].isVisible, "Photo should not be visible initially") - - viewModel.onAppear() - - XCTAssertTrue(testPhotos[0].isVisible, "Current photo should be marked as visible after onAppear") - } - - // MARK: - UI State Tests - - /// Tests that UI state properties can be updated correctly - /// Assertion: Should properly manage all UI state published properties - func testUIStateProperties_UpdateCorrectly() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: true) - - let expectation = XCTestExpectation(description: "UI state should update") - expectation.expectedFulfillmentCount = 8 - - // Monitor state changes - viewModel.$showDeleteConfirmation.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$isSwiping.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$processingFaces.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$showBlurConfirmation.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$showMaskOptions.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$showImageInfo.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$offset.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - viewModel.$imageFrameSize.dropFirst().sink { _ in expectation.fulfill() }.store(in: &cancellables) - - // Update states - viewModel.showDeleteConfirmation = true - viewModel.isSwiping = true - viewModel.processingFaces = true - viewModel.showBlurConfirmation = true - viewModel.showMaskOptions = true - viewModel.showImageInfo = true - viewModel.offset = 100 - viewModel.imageFrameSize = CGSize(width: 300, height: 400) - - wait(for: [expectation], timeout: 2.0) - } - - // MARK: - Sharing Tests - - /// Tests that sharePhoto method doesn't crash when executed - /// Assertion: Should handle sharing functionality without crashing - func testSharePhoto_DoesNotCrash() { - viewModel = PhotoDetailViewModel(allPhotos: testPhotos, initialIndex: 0, showFaceDetection: false) - - // Note: We can't fully test sharing functionality in unit tests since it requires UIKit view controller hierarchy - // But we can test that the method doesn't crash when called - XCTAssertNoThrow(viewModel.sharePhoto(), "Share photo should not crash when called") - } - - // MARK: - Helper Methods - - /// Creates test photos for use in tests - private func createTestPhotos() -> [SecurePhoto] { - let photos = (0..<3).map { index in - let testImage = createTestImage() - let metadata: [String: Any] = [ - "creationDate": Date().timeIntervalSince1970 - Double(index * 3600), - "testPhoto": true, - "index": index - ] - return SecurePhoto( - filename: "test_photo_\(index)", - metadata: metadata, - fileURL: URL(fileURLWithPath: "/tmp/test_\(index).jpg"), - preloadedThumbnail: testImage - ) - } - return photos - } - - /// Creates a test image for use in tests - private func createTestImage(size: CGSize = CGSize(width: 200, height: 200)) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { context in - context.cgContext.setFillColor(UIColor.blue.cgColor) - context.cgContext.fill(CGRect(origin: .zero, size: size)) - - context.cgContext.setFillColor(UIColor.white.cgColor) - context.cgContext.fillEllipse(in: CGRect(x: size.width * 0.25, y: size.height * 0.25, - width: size.width * 0.5, height: size.height * 0.5)) - } - } -} \ No newline at end of file diff --git a/SnapSafeTests/PhotoStorageDataSourceTests.swift b/SnapSafeTests/PhotoStorageDataSourceTests.swift new file mode 100644 index 0000000..7d53227 --- /dev/null +++ b/SnapSafeTests/PhotoStorageDataSourceTests.swift @@ -0,0 +1,104 @@ +// +// PhotoStorageDataSourceTests.swift +// SnapSafeTests +// + +import XCTest +@testable import SnapSafe + +final class PhotoStorageDataSourceTests: XCTestCase { + + private var tempRoot: URL! + private var cachesRoot: URL! + + override func setUpWithError() throws { + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("psds-\(UUID().uuidString)") + cachesRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("psds-caches-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cachesRoot, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempRoot) + try? FileManager.default.removeItem(at: cachesRoot) + } + + private func makeDataSource(encryptionScheme: EncryptionScheme) -> PhotoStorageDataSource { + PhotoStorageDataSource( + encryptionScheme: encryptionScheme, appSupportRoot: tempRoot, cachesRoot: cachesRoot) + } + + func test_getGalleryDirectory_createsDirUnderRoot_andExcludesFromBackup() throws { + // Use any EncryptionScheme double; directory creation does not touch crypto. + let ds = makeDataSource(encryptionScheme: FakeEncryptionScheme()) + let dir = ds.getGalleryDirectory() + let expected = tempRoot.appendingPathComponent(PhotoStorageDataSource.photosDir) + // Compare paths (not URLs) to avoid trailing-slash mismatches introduced by createDirectory. + XCTAssertEqual(dir.standardized.path, expected.standardized.path, + "gallery dir should be /photos; got \(dir.path)") + XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path), "directory should be created") + let values = try dir.resourceValues(forKeys: [.isExcludedFromBackupKey]) + XCTAssertEqual(values.isExcludedFromBackup, true, "directory must be excluded from backup") + } + + func test_thumbnailsDirectory_isUnderCachesRoot() { + let ds = makeDataSource(encryptionScheme: FakeEncryptionScheme()) + let dir = ds.getThumbnailsDirectory() + let expected = cachesRoot.appendingPathComponent(PhotoStorageDataSource.thumbnailsDir) + // Compare paths (not URLs) to avoid trailing-slash mismatches. + XCTAssertEqual(dir.standardized.path, expected.standardized.path, + "thumbnails dir should be under cachesRoot; got \(dir.path)") + } + + func test_encryptToFile_thenDecryptFile_roundTripsBytes() async throws { + // PassThroughEncryptionScheme performs real file I/O (write/read) without + // encryption, so it exercises the data source's file plumbing end to end. + let ds = makeDataSource(encryptionScheme: PassThroughEncryptionScheme()) + let target = tempRoot.appendingPathComponent("roundtrip.bin") + let original = Data("hello secure storage".utf8) + try await ds.encryptToFile(original, targetFile: target) + let recovered = try await ds.decryptFile(target) + XCTAssertEqual(recovered, original, "decryptFile must return the bytes written by encryptToFile") + } + + func test_encryptAndSaveImage_movesTempIntoTarget_andIsReadable() async throws { + let ds = makeDataSource(encryptionScheme: PassThroughEncryptionScheme()) + let tempFile = tempRoot.appendingPathComponent("staging.tmp") + let target = tempRoot.appendingPathComponent("final.bin") + let original = Data([0xDE, 0xAD, 0xBE, 0xEF]) + try await ds.encryptAndSaveImage(original, tempFile: tempFile, targetFile: target) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempFile.path), + "temp file should be moved to the target, not left behind") + XCTAssertTrue(FileManager.default.fileExists(atPath: target.path), "target file should exist") + let recovered = try await ds.decryptFile(target) + XCTAssertEqual(recovered, original, "round-tripped bytes should match the original") + } + + func test_decoyEnumeration_filtersByExtension() throws { + let ds = makeDataSource(encryptionScheme: PassThroughEncryptionScheme()) + let decoyDir = ds.getDecoyDirectory() + try Data().write(to: decoyDir.appendingPathComponent("a.jpg")) + try Data().write(to: decoyDir.appendingPathComponent("b.jpg")) + try Data().write(to: decoyDir.appendingPathComponent("c.secv")) + try Data().write(to: decoyDir.appendingPathComponent("note.txt")) + + let jpgs = ds.getDecoyFiles().map { $0.lastPathComponent }.sorted() + XCTAssertEqual(jpgs, ["a.jpg", "b.jpg"], "getDecoyFiles should return only .jpg files; got \(jpgs)") + + let secvs = ds.getDecoyVideoFiles().map { $0.lastPathComponent } + XCTAssertEqual(secvs, ["c.secv"], "getDecoyVideoFiles should return only .secv files; got \(secvs)") + } + + func test_getDecoyFile_isUnderDecoyDirectory() { + let ds = makeDataSource(encryptionScheme: PassThroughEncryptionScheme()) + let photo = PhotoDef(photoName: "photo_x.jpg", photoFormat: "jpg", + photoFile: tempRoot.appendingPathComponent("photo_x.jpg")) + let decoyFile = ds.getDecoyFile(photo) + XCTAssertEqual(decoyFile.lastPathComponent, "photo_x.jpg") + XCTAssertEqual(decoyFile.deletingLastPathComponent().standardized.path, + ds.getDecoyDirectory().standardized.path, + "decoy file should live under the decoy directory") + } +} diff --git a/SnapSafeTests/PinCryptoTests.swift b/SnapSafeTests/PinCryptoTests.swift index 21d0650..d013361 100644 --- a/SnapSafeTests/PinCryptoTests.swift +++ b/SnapSafeTests/PinCryptoTests.swift @@ -22,7 +22,7 @@ final class PinCryptoTests: XCTestCase { func test_hashPin_generatesSaltAndHash() throws { let pin = "1234" - let hashed = crypto.hashPin(pin: pin, deviceId: deviceId) + let hashed = try crypto.hashPin(pin: pin, deviceId: deviceId) XCTAssertFalse(hashed.salt.isEmpty, "Salt should not be empty") XCTAssertFalse(hashed.hash.isEmpty, "Hash should not be empty") @@ -31,8 +31,8 @@ final class PinCryptoTests: XCTestCase { func test_hashPin_generatesDifferentHashesForSamePIN() throws { let pin = "1234" - let h1 = crypto.hashPin(pin: pin, deviceId: deviceId) - let h2 = crypto.hashPin(pin: pin, deviceId: deviceId) + let h1 = try crypto.hashPin(pin: pin, deviceId: deviceId) + let h2 = try crypto.hashPin(pin: pin, deviceId: deviceId) XCTAssertNotEqual(h1.salt, h2.salt, "Salts should be different") XCTAssertNotEqual(h1.hash, h2.hash, "Hashes should be different") @@ -40,7 +40,7 @@ final class PinCryptoTests: XCTestCase { func test_verifyPin_returnsTrueForCorrectPIN() throws { let pin = "1234" - let hashed = crypto.hashPin(pin: pin, deviceId: deviceId) + let hashed = try crypto.hashPin(pin: pin, deviceId: deviceId) let ok = crypto.verifyPin(pin: pin, stored: hashed, deviceId: deviceId) XCTAssertTrue(ok, "Verification should succeed for correct PIN") @@ -48,7 +48,7 @@ final class PinCryptoTests: XCTestCase { func test_verifyPin_returnsFalseForIncorrectPIN() throws { let correctPin = "1234" - let hashed = crypto.hashPin(pin: correctPin, deviceId: deviceId) + let hashed = try crypto.hashPin(pin: correctPin, deviceId: deviceId) let result = crypto.verifyPin(pin: "5678", stored: hashed, deviceId: deviceId) XCTAssertFalse(result, "Verification should fail for incorrect PIN") @@ -56,7 +56,7 @@ final class PinCryptoTests: XCTestCase { func test_verifyPin_handlesEmptyPIN() throws { let correctPin = "1234" - let hashed = crypto.hashPin(pin: correctPin, deviceId: deviceId) + let hashed = try crypto.hashPin(pin: correctPin, deviceId: deviceId) let result = crypto.verifyPin(pin: "", stored: hashed, deviceId: deviceId) XCTAssertFalse(result, "Verification should fail for empty PIN") diff --git a/SnapSafeTests/PinDEKWrapperTests.swift b/SnapSafeTests/PinDEKWrapperTests.swift new file mode 100644 index 0000000..6d1f9e6 --- /dev/null +++ b/SnapSafeTests/PinDEKWrapperTests.swift @@ -0,0 +1,130 @@ +// +// PinDEKWrapperTests.swift +// SnapSafeTests +// +// Created by Claude on 2026-05-31. +// +// Tests the C1 fix: the DEK is wrapped under a PIN-derived key (AES-GCM) so the +// PIN is *cryptographically* required to recover the DEK, not just procedurally +// checked. This unit is keychain-free (CryptoKit + PBKDF2) and runs on the +// simulator / CI. See design/2026-05-31-c1-pin-binding-analysis. + +import CryptoKit +import Foundation +import XCTest + +@testable import SnapSafe + +final class PinDEKWrapperTests: XCTestCase { + + private let salt = Data("a-16-byte-salt!!".utf8) + private let deviceId = Data("device-identifier-bytes".utf8) + + // MARK: - PIN key derivation + + func test_derivePinKey_isDeterministicForSameInputs() throws { + let k1 = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + let k2 = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + XCTAssertEqual(k1.rawBytes, k2.rawBytes, "Same PIN/salt/device must derive the same key") + } + + func test_derivePinKey_differsForDifferentPins() throws { + let k1 = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + let k2 = try PinDEKWrapper.derivePinKey(plainPin: "9999", salt: salt, deviceId: deviceId) + XCTAssertNotEqual(k1.rawBytes, k2.rawBytes, "Different PINs must derive different keys") + } + + func test_derivePinKey_differsForDifferentDevices() throws { + let k1 = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + let k2 = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: Data("other-device".utf8)) + XCTAssertNotEqual(k1.rawBytes, k2.rawBytes, "Different devices must derive different keys") + } + + // MARK: - Wrap / unwrap round trip + + func test_wrapThenUnwrap_withSamePin_recoversDEK() throws { + let dek = try randomDEK() + let pinKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + + let wrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + let recovered = try PinDEKWrapper.unwrap(payload: wrapped, pinKey: pinKey) + + XCTAssertEqual(recovered, dek, "Unwrapping with the correct PIN key must recover the exact DEK") + } + + func test_unwrap_withWrongPin_throwsWrongPin() throws { + let dek = try randomDEK() + let rightKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + let wrongKey = try PinDEKWrapper.derivePinKey(plainPin: "0000", salt: salt, deviceId: deviceId) + + let wrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: rightKey) + + XCTAssertThrowsError(try PinDEKWrapper.unwrap(payload: wrapped, pinKey: wrongKey)) { error in + XCTAssertEqual(error as? CryptoError, CryptoError.wrongPin, + "A wrong PIN must surface as CryptoError.wrongPin, not a raw CryptoKit error") + } + } + + // MARK: - Wrapped-blob properties + + func test_wrap_doesNotLeakDEKInPlaintext() throws { + let dek = try randomDEK() + let pinKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + + let wrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + + XCTAssertFalse(wrapped.range(of: dek) != nil, "Wrapped payload must not contain the raw DEK bytes") + } + + func test_wrap_isNonDeterministic_dueToRandomNonce() throws { + let dek = try randomDEK() + let pinKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + + let a = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + let b = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + + XCTAssertNotEqual(a, b, "Each wrap must use a fresh random nonce") + // Both must still decrypt back to the same DEK. + XCTAssertEqual(try PinDEKWrapper.unwrap(payload: a, pinKey: pinKey), dek) + XCTAssertEqual(try PinDEKWrapper.unwrap(payload: b, pinKey: pinKey), dek) + } + + func test_wrappedPayload_hasExpectedLength() throws { + let dek = try randomDEK() + let pinKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + + let wrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) + + // 12-byte nonce + 32-byte ciphertext (== plaintext length) + 16-byte tag. + XCTAssertEqual(wrapped.count, 12 + 32 + 16) + } + + // MARK: - Legacy migration discriminator + + func test_isLegacyRawDEK_trueForRawDEKLength() throws { + let raw = try randomDEK() // 32 bytes + XCTAssertTrue(PinDEKWrapper.isLegacyRawDEK(raw)) + } + + func test_isLegacyRawDEK_falseForWrappedPayload() throws { + let dek = try randomDEK() + let pinKey = try PinDEKWrapper.derivePinKey(plainPin: "1234", salt: salt, deviceId: deviceId) + let wrapped = try PinDEKWrapper.wrap(dek: dek, pinKey: pinKey) // 60 bytes + XCTAssertFalse(PinDEKWrapper.isLegacyRawDEK(wrapped)) + } + + // MARK: - Helpers + + private func randomDEK() throws -> Data { + var bytes = Data(count: 32) + let result = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) + } + guard result == errSecSuccess else { throw CryptoError.randomGenerationFailed } + return bytes + } +} + +private extension SymmetricKey { + var rawBytes: Data { withUnsafeBytes { Data($0) } } +} diff --git a/SnapSafeTests/PinRepositoryTest.swift b/SnapSafeTests/PinRepositoryTest.swift index 7a0438d..acdf5bd 100644 --- a/SnapSafeTests/PinRepositoryTest.swift +++ b/SnapSafeTests/PinRepositoryTest.swift @@ -39,17 +39,17 @@ final class PinRepositoryTests: XCTestCase { func test_setAppPin_hashes_and_stores_ciphered_pin() async throws { let pin = "1234" - let hashed = HashedPin(hash: "hash123", salt: "salt123") - given(pinCrypto).hashPin(pin: .value(pin), deviceId: .value(deviceId)).willReturn(hashed) + let baseHashed = HashedPin(hash: "hash123", salt: "salt123") + given(pinCrypto).hashPin(pin: .value(pin), deviceId: .value(deviceId)).willReturn(baseHashed) - let hashedData = try jsonEncoder().encode(hashed) + let hashedData = try jsonEncoder().encode(baseHashed) let encryptedData = Data("encrypted".utf8) let expectedBase64 = encryptedData.base64EncodedString() given(encryptionScheme).encryptWithKeyAlias( plain: .value(hashedData), keyAlias: .value("pin_key") ).willReturn(encryptedData) - + given(settings).setAppPin(cipheredPin: .value(expectedBase64)) .willReturn() @@ -89,7 +89,7 @@ final class PinRepositoryTests: XCTestCase { let hashed = HashedPin(hash: "hh", salt: "ss") given(pinCrypto).hashPin(pin: .value(pin), deviceId: .value(deviceId)).willReturn(hashed) - let result = await repo.hashPin(pin) + let result = try await repo.hashPin(pin) XCTAssertEqual(hashed, result) } @@ -131,14 +131,14 @@ final class PinRepositoryTests: XCTestCase { let ppp = "5678" let hashed = HashedPin(hash: "ph", salt: "ps") given(pinCrypto).hashPin(pin: .value(ppp), deviceId: .value(deviceId)).willReturn(hashed) - - let hashedData = try! jsonEncoder().encode(hashed) + + let hashedData = try jsonEncoder().encode(hashed) let plainData = ppp.data(using: .utf8)! let encryptedHashedData = Data("encrypted-hashed".utf8) let encryptedPlainData = Data("encrypted-plain".utf8) let expectedHashedBase64 = encryptedHashedData.base64EncodedString() let expectedPlainBase64 = encryptedPlainData.base64EncodedString() - + given(encryptionScheme).encryptWithKeyAlias( plain: .matching { $0 == hashedData }, keyAlias: .value("pin_key") @@ -147,7 +147,7 @@ final class PinRepositoryTests: XCTestCase { plain: .matching { $0 == plainData }, keyAlias: .value("pin_key") ).willReturn(encryptedPlainData) - + given(settings).setPoisonPillPin( cipheredHashedPin: .value(expectedHashedBase64), cipheredPlainPin: .value(expectedPlainBase64), diff --git a/SnapSafeTests/PinStrengthCheckUseCaseTests.swift b/SnapSafeTests/PinStrengthCheckUseCaseTests.swift new file mode 100644 index 0000000..b000c77 --- /dev/null +++ b/SnapSafeTests/PinStrengthCheckUseCaseTests.swift @@ -0,0 +1,87 @@ +// +// PinStrengthCheckUseCaseTests.swift +// SnapSafeTests +// + +import XCTest +@testable import SnapSafe + +final class PinStrengthCheckUseCaseTests: XCTestCase { + + private var sut: PinStrengthCheckUseCase! + + override func setUp() { + sut = PinStrengthCheckUseCase() + } + + // MARK: - Numeric tests (existing behaviour preserved) + + func test_numeric_validPin_isStrong() { + XCTAssertTrue(sut.isPinStrongEnough("2847", isAlphanumeric: false)) + XCTAssertTrue(sut.isPinStrongEnough("739182", isAlphanumeric: false)) + } + + func test_numeric_tooShort_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("123", isAlphanumeric: false)) + } + + func test_numeric_allSameDigits_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("1111", isAlphanumeric: false)) + XCTAssertFalse(sut.isPinStrongEnough("999999", isAlphanumeric: false)) + } + + func test_numeric_ascendingSequence_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("1234", isAlphanumeric: false)) + XCTAssertFalse(sut.isPinStrongEnough("456789", isAlphanumeric: false)) + } + + func test_numeric_descendingSequence_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("9876", isAlphanumeric: false)) + } + + func test_numeric_blacklist_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("1212", isAlphanumeric: false)) + XCTAssertFalse(sut.isPinStrongEnough("6969", isAlphanumeric: false)) + } + + func test_numeric_containsLetters_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("12a4", isAlphanumeric: false)) + } + + // MARK: - Alphanumeric tests + + func test_alphanumeric_validMixed_isStrong() { + XCTAssertTrue(sut.isPinStrongEnough("ab92", isAlphanumeric: true)) + XCTAssertTrue(sut.isPinStrongEnough("Tr0ub4", isAlphanumeric: true)) + } + + func test_alphanumeric_lettersOnly_isStrong() { + XCTAssertTrue(sut.isPinStrongEnough("flux", isAlphanumeric: true)) + } + + func test_alphanumeric_tooShort_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("ab3", isAlphanumeric: true)) + } + + func test_alphanumeric_allSameChar_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("aaaa", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("1111", isAlphanumeric: true)) + } + + func test_alphanumeric_commonPassword_isWeak() { + XCTAssertFalse(sut.isPinStrongEnough("password", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("PASSWORD", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("letmein", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("abc123", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("qwerty", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("iloveyou", isAlphanumeric: true)) + XCTAssertFalse(sut.isPinStrongEnough("abcd1234", isAlphanumeric: true)) + } + + // MARK: - Default is numeric (isAlphanumeric: false) + + func test_defaultPinType_behavesAsNumeric() { + XCTAssertTrue(sut.isPinStrongEnough("2847")) // strong numeric + XCTAssertFalse(sut.isPinStrongEnough("1234")) // sequence + } +} diff --git a/SnapSafeTests/PoisonPillVideoDeletionTests.swift b/SnapSafeTests/PoisonPillVideoDeletionTests.swift new file mode 100644 index 0000000..5f10a50 --- /dev/null +++ b/SnapSafeTests/PoisonPillVideoDeletionTests.swift @@ -0,0 +1,156 @@ +// +// PoisonPillVideoDeletionTests.swift +// SnapSafeTests +// +// Verifies poison-pill video handling: non-decoy videos are destroyed, while +// decoy videos are re-encrypted with the poison-pill key and survive (and are +// swapped in to replace the original real-key file). +// + +import XCTest +@testable import SnapSafe + +@MainActor +final class PoisonPillVideoDeletionTests: XCTestCase { + + private var repository: SecureImageRepository! + private var tempDirectory: URL! + private var galleryDirectory: URL! + private var decoyDirectory: URL! + private var videosDirectory: URL! + + override func setUp() async throws { + try await super.setUp() + + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + galleryDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.photosDir) + decoyDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.decoysDir) + videosDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videosDir) + + repository = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: FakeVideoEncryptionService(), + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: tempDirectory) + repository = nil + tempDirectory = nil + galleryDirectory = nil + decoyDirectory = nil + videosDirectory = nil + try await super.tearDown() + } + + /// Core regression test: when the poison pill is activated, a decoy photo is + /// preserved while non-decoy videos are destroyed. + func testActivatePoisonPillDestroysVideosNotMarkedAsDecoys() async throws { + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + + // A decoy photo (present in gallery, backed up in the decoy directory) - survives. + let decoyPhoto = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") + try Data().write(to: decoyPhoto) + let decoyBackup = decoyDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") + try Data("decoy".utf8).write(to: decoyBackup) + + // A regular (non-decoy) photo - destroyed. + let regularPhoto = galleryDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") + try Data().write(to: regularPhoto) + + // Videos - none are decoys, so all must be destroyed. + let video1 = videosDirectory.appendingPathComponent("video_20230101_120000.secv") + let video2 = videosDirectory.appendingPathComponent("video_20230101_120100.secv") + try Data().write(to: video1) + try Data().write(to: video2) + + // When + await repository.activatePoisonPill() + + // Then - only the decoy photo survives. + let photos = await repository.getPhotos() + XCTAssertEqual(photos.count, 1) + XCTAssertEqual(photos.first?.photoName, "photo_20230101_120000_00.jpg") + + // And the non-decoy videos are destroyed. + XCTAssertFalse(FileManager.default.fileExists(atPath: video1.path), + "Non-decoy video should be destroyed when the poison pill is activated") + XCTAssertFalse(FileManager.default.fileExists(atPath: video2.path), + "Non-decoy video should be destroyed when the poison pill is activated") + } + + /// Adding a decoy video re-encrypts it with the poison-pill key into the + /// decoy directory and marks it as a decoy. + func testAddDecoyVideoReEncryptsAndMarksDecoy() async throws { + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + + let videoFile = videosDirectory.appendingPathComponent("video_20230101_120000.secv") + try Data("original-real-key".utf8).write(to: videoFile) + let videoDef = VideoDef(videoName: "video_20230101_120000", videoFormat: "secv", videoFile: videoFile) + + let fakeVideo = FakeVideoEncryptionService() + let repo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: fakeVideo, + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + + // When + let success = await repo.addDecoyVideoWithKey(videoDef, keyData: Data(repeating: 0xAB, count: 32)) + + // Then + XCTAssertTrue(success) + XCTAssertTrue(fakeVideo.decryptForSharingCalled, "Should decrypt the original with the current key") + XCTAssertTrue(fakeVideo.encryptForDecoyCalled, "Should re-encrypt with the poison-pill key") + let isDecoy = await repo.isDecoyVideo(videoDef) + XCTAssertTrue(isDecoy, "Video should be marked as a decoy") + + let decoyCopy = decoyDirectory.appendingPathComponent("video_20230101_120000.secv") + XCTAssertTrue(FileManager.default.fileExists(atPath: decoyCopy.path)) + XCTAssertEqual(try Data(contentsOf: decoyCopy), FakeVideoEncryptionService.reEncryptedMarker) + } + + /// End-to-end: mark a video as a decoy, then activate the poison pill. The + /// decoy video survives and its file is replaced by the poison-pill-key copy, + /// while a non-decoy video alongside it is destroyed. + func testActivatePoisonPillReplacesDecoyVideoWithReEncryptedCopy() async throws { + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + + // Decoy video — original encrypted with the (now-doomed) real key. + let decoyVideoFile = videosDirectory.appendingPathComponent("video_decoy.secv") + try Data("original-real-key".utf8).write(to: decoyVideoFile) + let decoyVideoDef = VideoDef(videoName: "video_decoy", videoFormat: "secv", videoFile: decoyVideoFile) + + // Non-decoy video. + let regularVideo = videosDirectory.appendingPathComponent("video_regular.secv") + try Data("regular".utf8).write(to: regularVideo) + + // Mark the decoy video (re-encrypts into the decoy dir with the poison key). + let added = await repository.addDecoyVideoWithKey(decoyVideoDef, keyData: Data(repeating: 0xAB, count: 32)) + XCTAssertTrue(added) + let isDecoy = await repository.isDecoyVideo(decoyVideoDef) + XCTAssertTrue(isDecoy) + + // When + await repository.activatePoisonPill() + + // Then - decoy video survives and now holds the poison-pill-key bytes. + XCTAssertTrue(FileManager.default.fileExists(atPath: decoyVideoFile.path), + "Decoy video should survive poison pill activation") + XCTAssertEqual(try Data(contentsOf: decoyVideoFile), FakeVideoEncryptionService.reEncryptedMarker, + "Decoy video should be replaced by its poison-pill-key copy") + + // And the non-decoy video is destroyed. + XCTAssertFalse(FileManager.default.fileExists(atPath: regularVideo.path), + "Non-decoy video should be destroyed") + } +} diff --git a/SnapSafeTests/SECVFileFormatTests.swift b/SnapSafeTests/SECVFileFormatTests.swift new file mode 100644 index 0000000..b8b7ab6 --- /dev/null +++ b/SnapSafeTests/SECVFileFormatTests.swift @@ -0,0 +1,156 @@ +// +// SECVFileFormatTests.swift +// SnapSafeTests +// +// Created by Claude on 1/26/26. +// + +import XCTest +@testable import SnapSafe + +final class SECVFileFormatTests: XCTestCase { + + func testTrailerSerialization() throws { + // Create a test trailer + let trailer = SECVFileFormat.SecvTrailer( + version: SECVFileFormat.VERSION, + chunkSize: UInt32(SECVFileFormat.DEFAULT_CHUNK_SIZE), + totalChunks: 42, + originalSize: 10485760 // 10MB + ) + + // Convert to data + let data = trailer.toData() + + // Verify data size + XCTAssertEqual(data.count, SECVFileFormat.TRAILER_SIZE, "Trailer data should be exactly 64 bytes") + + // Parse back from data + let parsedTrailer = try SECVFileFormat.SecvTrailer.from(data: data) + + // Verify all fields match + XCTAssertEqual(parsedTrailer.version, trailer.version, "Version should match") + XCTAssertEqual(parsedTrailer.chunkSize, trailer.chunkSize, "Chunk size should match") + XCTAssertEqual(parsedTrailer.totalChunks, trailer.totalChunks, "Total chunks should match") + XCTAssertEqual(parsedTrailer.originalSize, trailer.originalSize, "Original size should match") + } + + func testChunkIndexEntrySerialization() throws { + // Create a test chunk index entry + let entry = SECVFileFormat.ChunkIndexEntry( + offset: 1048576, + encryptedSize: UInt32(1048576 + SECVFileFormat.IV_SIZE + SECVFileFormat.AUTH_TAG_SIZE) + ) + + // Convert to data + let data = entry.toData() + + // Verify data size + XCTAssertEqual(data.count, SECVFileFormat.CHUNK_INDEX_ENTRY_SIZE, "Chunk index entry should be exactly 12 bytes") + + // Parse back from data + let parsedEntry = try SECVFileFormat.ChunkIndexEntry.from(data: data) + + // Verify all fields match + XCTAssertEqual(parsedEntry.offset, entry.offset, "Offset should match") + XCTAssertEqual(parsedEntry.encryptedSize, entry.encryptedSize, "Encrypted size should match") + } + + func testEncryptedChunkSizeCalculation() { + // Test with 1MB chunk + let chunkSize = SECVFileFormat.DEFAULT_CHUNK_SIZE + let encryptedSize = SECVFileFormat.calculateEncryptedChunkSize(plaintextSize: chunkSize) + + let expectedSize = SECVFileFormat.IV_SIZE + chunkSize + SECVFileFormat.AUTH_TAG_SIZE + XCTAssertEqual(encryptedSize, expectedSize, "Encrypted chunk size should be IV + plaintext + auth tag") + } + + func testTrailerPositionCalculation() { + // Test with a 10MB file + let fileSize: UInt64 = 10_485_760 + let trailerPosition = SECVFileFormat.calculateTrailerPosition(fileLength: fileSize) + + let expectedPosition = fileSize - UInt64(SECVFileFormat.TRAILER_SIZE) + XCTAssertEqual(trailerPosition, expectedPosition, "Trailer should be at fileSize - 64") + } + + func testIndexTablePositionCalculation() { + // Test with a 10MB file and 10 chunks + let fileSize: UInt64 = 10_485_760 + let totalChunks: UInt64 = 10 + let indexTablePosition = SECVFileFormat.calculateIndexTablePosition(fileLength: fileSize, totalChunks: totalChunks) + + let expectedPosition = fileSize - UInt64(SECVFileFormat.TRAILER_SIZE) - (totalChunks * UInt64(SECVFileFormat.CHUNK_INDEX_ENTRY_SIZE)) + XCTAssertEqual(indexTablePosition, expectedPosition, "Index table position calculation should be correct") + } + + func testPlaintextOffsetCalculation() { + // Test offset calculation for chunk index 5 with 1MB chunks + let chunkIndex: UInt64 = 5 + let chunkSize: UInt32 = UInt32(SECVFileFormat.DEFAULT_CHUNK_SIZE) + let offset = SECVFileFormat.calculatePlaintextOffset(chunkIndex: chunkIndex, chunkSize: chunkSize) + + let expectedOffset = chunkIndex * UInt64(chunkSize) + XCTAssertEqual(offset, expectedOffset, "Plaintext offset should be chunkIndex * chunkSize") + } + + func testTotalFileSizeCalculation() { + // Test with 10MB original file and 10 chunks + let originalSize: UInt64 = 10_485_760 + let totalChunks: UInt64 = 10 + + let totalFileSize = SECVFileFormat.calculateTotalFileSize(originalSize: originalSize, totalChunks: totalChunks) + + // Calculate expected size manually + let encryptedDataSize = totalChunks * UInt64(SECVFileFormat.DEFAULT_CHUNK_SIZE + SECVFileFormat.IV_SIZE + SECVFileFormat.AUTH_TAG_SIZE) + let indexTableSize = totalChunks * UInt64(SECVFileFormat.CHUNK_INDEX_ENTRY_SIZE) + let expectedSize = encryptedDataSize + indexTableSize + UInt64(SECVFileFormat.TRAILER_SIZE) + + XCTAssertEqual(totalFileSize, expectedSize, "Total file size calculation should be correct") + } + + func testInvalidTrailerParsing() { + // Test parsing invalid trailer data + let invalidData = Data(repeating: 0, count: SECVFileFormat.TRAILER_SIZE - 1) + + XCTAssertThrowsError(try SECVFileFormat.SecvTrailer.from(data: invalidData), "Should throw error for invalid trailer size") { + error in + XCTAssertTrue(error is SECVError, "Should throw SECVError") + if let secvError = error as? SECVError { + XCTAssertEqual(secvError, SECVError.invalidTrailerSize, "Should be invalidTrailerSize error") + } + } + } + + func testInvalidMagicParsing() { + // Test parsing trailer with invalid magic + var invalidData = Data(repeating: 0, count: SECVFileFormat.TRAILER_SIZE) + invalidData.replaceSubrange(0..<4, with: "INVL".data(using: .ascii) ?? Data()) + + XCTAssertThrowsError(try SECVFileFormat.SecvTrailer.from(data: invalidData), "Should throw error for invalid magic") { + error in + XCTAssertTrue(error is SECVError, "Should throw SECVError") + if let secvError = error as? SECVError { + XCTAssertEqual(secvError, SECVError.invalidMagic, "Should be invalidMagic error") + } + } + } + + func testVideoDefEncryptionDetection() { + // Test VideoDef encryption detection + let encryptedVideo = VideoDef( + videoName: "video_20260126_120000", + videoFormat: SECVFileFormat.FILE_EXTENSION, + videoFile: URL(fileURLWithPath: "/test/video.secv") + ) + + let unencryptedVideo = VideoDef( + videoName: "video_20260126_120000", + videoFormat: "mov", + videoFile: URL(fileURLWithPath: "/test/video.mov") + ) + + XCTAssertTrue(encryptedVideo.isEncrypted, "Should detect .secv as encrypted") + XCTAssertFalse(unencryptedVideo.isEncrypted, "Should detect .mov as unencrypted") + } +} \ No newline at end of file diff --git a/SnapSafeTests/SecureFileManagerTests.swift b/SnapSafeTests/SecureFileManagerTests.swift deleted file mode 100644 index defa8bd..0000000 --- a/SnapSafeTests/SecureFileManagerTests.swift +++ /dev/null @@ -1,438 +0,0 @@ -// -// SecureFileManagerTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/25/25. -// - -import XCTest -import Foundation -import UIKit -@testable import SnapSafe - -class SecureFileManagerTests: XCTestCase { - - private var secureFileManager: SecureFileManager! - private var testPhotoData: Data! - - override func setUp() { - super.setUp() - secureFileManager = SecureFileManager() - - // Create minimal JPEG test data - testPhotoData = createTestJPEGData() - - // Clean up any existing test files - try? secureFileManager.deleteAllPhotos() - } - - override func tearDown() { - // Clean up test files after each test - try? secureFileManager.deleteAllPhotos() - secureFileManager = nil - testPhotoData = nil - super.tearDown() - } - - // MARK: - Secure Directory Tests - - /// Tests that getSecureDirectory() creates and returns a valid secure directory - /// Assertion: Directory should exist, be within Documents folder, and have backup exclusion - func testGetSecureDirectory_CreatesValidSecureDirectory() throws { - let secureDirectory = try secureFileManager.getSecureDirectory() - - // Assert directory exists - XCTAssertTrue(FileManager.default.fileExists(atPath: secureDirectory.path), - "Secure directory should exist after creation") - - // Assert it's within Documents directory - XCTAssertTrue(secureDirectory.path.contains("Documents/SecurePhotos"), - "Secure directory should be within Documents/SecurePhotos") - - // Assert backup exclusion attribute is set - let resourceValues = try secureDirectory.resourceValues(forKeys: [.isExcludedFromBackupKey]) - XCTAssertTrue(resourceValues.isExcludedFromBackup == true, - "Secure directory should be excluded from backup") - } - - /// Tests that calling getSecureDirectory() multiple times returns the same directory - /// Assertion: Multiple calls should return identical URLs without creating duplicates - func testGetSecureDirectory_ConsistentResults() throws { - let directory1 = try secureFileManager.getSecureDirectory() - let directory2 = try secureFileManager.getSecureDirectory() - - XCTAssertEqual(directory1, directory2, - "Multiple calls to getSecureDirectory should return the same URL") - } - - // MARK: - Photo Saving Tests - - /// Tests that savePhoto() successfully saves photo data and metadata to secure storage - /// Assertion: Photo should be saved with valid filename and retrievable data - func testSavePhoto_SavesPhotoSuccessfully() throws { - let testMetadata = ["testKey": "testValue", "imageWidth": 1024, "imageHeight": 768] as [String: Any] - - let filename = try secureFileManager.savePhoto(testPhotoData, withMetadata: testMetadata) - - // Assert filename is not empty - XCTAssertFalse(filename.isEmpty, "Saved photo should have a valid filename") - - // Assert photo can be loaded back - let (loadedData, loadedMetadata) = try secureFileManager.loadPhoto(filename: filename) - XCTAssertEqual(loadedData, testPhotoData, "Loaded photo data should match original data") - - // Assert metadata includes our test data plus creation date - XCTAssertEqual(loadedMetadata["testKey"] as? String, "testValue", "Custom metadata should be preserved") - XCTAssertEqual(loadedMetadata["imageWidth"] as? Int, 1024, "Image width metadata should be preserved") - XCTAssertNotNil(loadedMetadata["creationDate"], "Creation date should be automatically added") - } - - /// Tests that savePhoto() generates unique filenames for concurrent saves - /// Assertion: Multiple photos saved in sequence should have unique filenames - func testSavePhoto_GeneratesUniqueFilenames() throws { - let filename1 = try secureFileManager.savePhoto(testPhotoData) - let filename2 = try secureFileManager.savePhoto(testPhotoData) - let filename3 = try secureFileManager.savePhoto(testPhotoData) - - XCTAssertNotEqual(filename1, filename2, "Consecutive saves should generate unique filenames") - XCTAssertNotEqual(filename2, filename3, "Consecutive saves should generate unique filenames") - XCTAssertNotEqual(filename1, filename3, "Consecutive saves should generate unique filenames") - - // Verify all filenames contain timestamp and UUID components - XCTAssertTrue(filename1.contains("_"), "Filename should contain timestamp_UUID format") - XCTAssertTrue(filename2.contains("_"), "Filename should contain timestamp_UUID format") - XCTAssertTrue(filename3.contains("_"), "Filename should contain timestamp_UUID format") - } - - /// Tests that savePhoto() properly handles empty photo data - /// Assertion: Empty data should be saved without throwing errors - func testSavePhoto_HandlesEmptyData() throws { - let emptyData = Data() - - XCTAssertNoThrow({ - let filename = try self.secureFileManager.savePhoto(emptyData) - XCTAssertFalse(filename.isEmpty, "Should generate filename even for empty data") - - let (loadedData, _) = try self.secureFileManager.loadPhoto(filename: filename) - XCTAssertEqual(loadedData, emptyData, "Empty data should be preserved") - }, "Saving empty photo data should not throw") - } - - /// Tests that savePhoto() properly cleans and serializes complex metadata - /// Assertion: Non-JSON serializable metadata should be filtered out, valid data preserved - func testSavePhoto_CleansComplexMetadata() throws { - let complexMetadata: [String: Any] = [ - "validString": "test", - "validInt": 42, - "validDouble": 3.14, - "validBool": true, - "validArray": ["item1", "item2", 123], - "validDict": ["nested": "value"], - "invalidData": Data([0x01, 0x02, 0x03]), // Should be filtered out - "invalidDate": Date(), // Should be filtered out - ] - - let filename = try secureFileManager.savePhoto(testPhotoData, withMetadata: complexMetadata) - let (_, loadedMetadata) = try secureFileManager.loadPhoto(filename: filename) - - // Assert valid metadata is preserved - XCTAssertEqual(loadedMetadata["validString"] as? String, "test") - XCTAssertEqual(loadedMetadata["validInt"] as? Int, 42) - XCTAssertEqual(loadedMetadata["validDouble"] as? Double, 3.14) - XCTAssertEqual(loadedMetadata["validBool"] as? Bool, true) - XCTAssertNotNil(loadedMetadata["validArray"]) - XCTAssertNotNil(loadedMetadata["validDict"]) - - // Assert invalid metadata is filtered out - XCTAssertNil(loadedMetadata["invalidData"], "Non-JSON serializable data should be filtered out") - XCTAssertNil(loadedMetadata["invalidDate"], "Non-JSON serializable date should be filtered out") - - // Assert creation date is still added - XCTAssertNotNil(loadedMetadata["creationDate"], "Creation date should always be added") - } - - // MARK: - Photo Loading Tests - - /// Tests that loadPhoto() throws appropriate error for non-existent files - /// Assertion: Loading non-existent photo should throw file not found error - func testLoadPhoto_ThrowsForNonExistentFile() { - let nonExistentFilename = "nonexistent_photo_12345" - - XCTAssertThrowsError(try secureFileManager.loadPhoto(filename: nonExistentFilename)) { error in - // Assert it's a file not found error - let nsError = error as NSError - XCTAssertEqual(nsError.domain, NSCocoaErrorDomain, "Should be a Cocoa framework error") - XCTAssertEqual(nsError.code, NSFileReadNoSuchFileError, "Should be file not found error") - } - } - - /// Tests that loadAllPhotoMetadata() returns correct metadata without loading image data - /// Assertion: Should return all saved photos with metadata but without heavy image data - func testLoadAllPhotoMetadata_ReturnsMetadataWithoutImageData() throws { - // Save multiple test photos - let filename1 = try secureFileManager.savePhoto(testPhotoData, withMetadata: ["photo": "first"]) - let filename2 = try secureFileManager.savePhoto(testPhotoData, withMetadata: ["photo": "second"]) - - let allMetadata = try secureFileManager.loadAllPhotoMetadata() - - XCTAssertEqual(allMetadata.count, 2, "Should return metadata for all saved photos") - - // Assert filenames are present - let filenames = allMetadata.map { $0.filename } - XCTAssertTrue(filenames.contains(filename1), "Should contain first photo filename") - XCTAssertTrue(filenames.contains(filename2), "Should contain second photo filename") - - // Assert metadata is loaded - for photoInfo in allMetadata { - XCTAssertNotNil(photoInfo.metadata["creationDate"], "Each photo should have creation date") - XCTAssertNotNil(photoInfo.fileURL, "Each photo should have valid file URL") - } - } - - /// Tests that loadPhotoThumbnail() generates appropriately sized thumbnails - /// Assertion: Thumbnail should be smaller than specified max size - func testLoadPhotoThumbnail_GeneratesCorrectSizedThumbnail() throws { - let filename = try secureFileManager.savePhoto(testPhotoData) - let secureDirectory = try secureFileManager.getSecureDirectory() - let fileURL = secureDirectory.appendingPathComponent("\(filename).photo") - - let maxSize: CGFloat = 100 - let thumbnail = try secureFileManager.loadPhotoThumbnail(from: fileURL, maxSize: maxSize) - - XCTAssertNotNil(thumbnail, "Should generate thumbnail for valid image data") - - if let thumbnail = thumbnail { - XCTAssertLessThanOrEqual(thumbnail.size.width, maxSize, "Thumbnail width should not exceed maxSize") - XCTAssertLessThanOrEqual(thumbnail.size.height, maxSize, "Thumbnail height should not exceed maxSize") - } - } - - /// Tests that loadPhotoThumbnail() handles invalid image data gracefully - /// Assertion: Invalid image data should return nil without throwing - func testLoadPhotoThumbnail_HandlesInvalidImageData() throws { - // Save invalid image data - let invalidData = "This is not image data".data(using: .utf8)! - let filename = try secureFileManager.savePhoto(invalidData) - let secureDirectory = try secureFileManager.getSecureDirectory() - let fileURL = secureDirectory.appendingPathComponent("\(filename).photo") - - let thumbnail = try secureFileManager.loadPhotoThumbnail(from: fileURL) - - XCTAssertNil(thumbnail, "Should return nil for invalid image data") - } - - // MARK: - Photo Deletion Tests - - /// Tests that deletePhoto() removes both photo and metadata files - /// Assertion: After deletion, files should not exist and loading should throw error - func testDeletePhoto_RemovesBothPhotoAndMetadata() throws { - let filename = try secureFileManager.savePhoto(testPhotoData, withMetadata: ["test": "data"]) - let secureDirectory = try secureFileManager.getSecureDirectory() - let photoURL = secureDirectory.appendingPathComponent("\(filename).photo") - let metadataURL = secureDirectory.appendingPathComponent("\(filename).metadata") - - // Verify files exist before deletion - XCTAssertTrue(FileManager.default.fileExists(atPath: photoURL.path), "Photo file should exist before deletion") - XCTAssertTrue(FileManager.default.fileExists(atPath: metadataURL.path), "Metadata file should exist before deletion") - - try secureFileManager.deletePhoto(filename: filename) - - // Assert files no longer exist - XCTAssertFalse(FileManager.default.fileExists(atPath: photoURL.path), "Photo file should be deleted") - XCTAssertFalse(FileManager.default.fileExists(atPath: metadataURL.path), "Metadata file should be deleted") - - // Assert loading the photo now throws error - XCTAssertThrowsError(try secureFileManager.loadPhoto(filename: filename), - "Loading deleted photo should throw error") - } - - /// Tests that deletePhoto() handles non-existent files gracefully - /// Assertion: Deleting non-existent photo should not throw error - func testDeletePhoto_HandlesNonExistentFiles() { - let nonExistentFilename = "nonexistent_photo_98765" - - XCTAssertNoThrow(try secureFileManager.deletePhoto(filename: nonExistentFilename), - "Deleting non-existent photo should not throw error") - } - - /// Tests that deleteAllPhotos() removes all photos and metadata from secure directory - /// Assertion: After deleteAllPhotos(), directory should be empty - func testDeleteAllPhotos_RemovesAllFiles() throws { - // Save multiple photos - try secureFileManager.savePhoto(testPhotoData, withMetadata: ["photo": "1"]) - try secureFileManager.savePhoto(testPhotoData, withMetadata: ["photo": "2"]) - try secureFileManager.savePhoto(testPhotoData, withMetadata: ["photo": "3"]) - - // Verify photos exist - let metadataBeforeDeletion = try secureFileManager.loadAllPhotoMetadata() - XCTAssertEqual(metadataBeforeDeletion.count, 3, "Should have 3 photos before deletion") - - try secureFileManager.deleteAllPhotos() - - // Assert all photos are deleted - let metadataAfterDeletion = try secureFileManager.loadAllPhotoMetadata() - XCTAssertEqual(metadataAfterDeletion.count, 0, "Should have no photos after deleteAllPhotos()") - } - - // MARK: - Sharing Tests - - /// Tests that preparePhotoForSharing() creates temporary file with UUID filename - /// Assertion: Should create accessible temporary file with unique name - func testPreparePhotoForSharing_CreatesTemporaryFile() throws { - let tempURL = try secureFileManager.preparePhotoForSharing(imageData: testPhotoData) - - // Assert file is in temporary directory - XCTAssertTrue(tempURL.path.contains("tmp") || tempURL.path.contains("Temporary"), - "Share file should be in temporary directory") - - // Assert file exists and contains correct data - XCTAssertTrue(FileManager.default.fileExists(atPath: tempURL.path), - "Temporary share file should exist") - - let loadedData = try Data(contentsOf: tempURL) - XCTAssertEqual(loadedData, testPhotoData, "Temporary file should contain original image data") - - // Assert filename contains UUID pattern (36 characters) - let filename = tempURL.lastPathComponent - let uuidPart = filename.replacingOccurrences(of: ".jpg", with: "") - XCTAssertEqual(uuidPart.count, 36, "Filename should contain UUID (36 characters)") - - // Clean up - try? FileManager.default.removeItem(at: tempURL) - } - - /// Tests that preparePhotoForSharing() creates unique files for multiple calls - /// Assertion: Multiple calls should create different temporary files - func testPreparePhotoForSharing_CreatesUniqueFiles() throws { - let tempURL1 = try secureFileManager.preparePhotoForSharing(imageData: testPhotoData) - let tempURL2 = try secureFileManager.preparePhotoForSharing(imageData: testPhotoData) - - XCTAssertNotEqual(tempURL1, tempURL2, "Multiple calls should create unique temporary files") - - // Clean up - try? FileManager.default.removeItem(at: tempURL1) - try? FileManager.default.removeItem(at: tempURL2) - } - - // MARK: - Edited Photo Saving Tests - - /// Tests that savePhoto() with isEdited flag marks photos correctly - /// Assertion: Edited photos should have isEdited metadata and original filename link - func testSavePhoto_WithEditedParameters_ShouldSaveCorrectly() throws { - let metadata: [String: Any] = ["testKey": "testValue"] - - let filename = try secureFileManager.savePhoto( - testPhotoData, - withMetadata: metadata, - isEdited: true, - originalFilename: "original_test_photo" - ) - - XCTAssertFalse(filename.isEmpty, "Filename should not be empty") - - // Verify photo was saved by loading it - let (loadedData, loadedMetadata) = try secureFileManager.loadPhoto(filename: filename) - - // Verify data integrity - XCTAssertEqual(loadedData, testPhotoData, "Loaded photo data should match original") - - // Verify edited metadata was added - XCTAssertTrue(loadedMetadata["isEdited"] as? Bool == true, "Photo should be marked as edited") - XCTAssertEqual(loadedMetadata["originalFilename"] as? String, "original_test_photo", "Original filename should be preserved") - - // Verify original metadata was preserved - XCTAssertEqual(loadedMetadata["testKey"] as? String, "testValue", "Original metadata should be preserved") - - // Verify automatic metadata was added - XCTAssertNotNil(loadedMetadata["creationDate"], "Creation date should be added automatically") - } - - /// Tests that savePhoto() with isEdited but no original filename works correctly - /// Assertion: Should mark as edited without original filename link - func testSavePhoto_WithEditedFlagOnly_ShouldSaveWithoutOriginalFilename() throws { - let filename = try secureFileManager.savePhoto( - testPhotoData, - withMetadata: [:], - isEdited: true - ) - - XCTAssertFalse(filename.isEmpty, "Filename should not be empty") - - // Verify photo was saved and metadata is correct - let (_, loadedMetadata) = try secureFileManager.loadPhoto(filename: filename) - - // Verify edited flag was set - XCTAssertTrue(loadedMetadata["isEdited"] as? Bool == true, "Photo should be marked as edited") - - // Verify no original filename is present - XCTAssertNil(loadedMetadata["originalFilename"], "Original filename should not be present when not provided") - } - - /// Tests that normal photo saving (not edited) doesn't add edited metadata - /// Assertion: Normal photos should not have isEdited flags - func testSavePhoto_WithoutEditedFlag_ShouldNotHaveEditedMetadata() throws { - let filename = try secureFileManager.savePhoto(testPhotoData, withMetadata: [:]) - - XCTAssertFalse(filename.isEmpty, "Filename should not be empty") - - // Verify photo was saved without edited metadata - let (_, loadedMetadata) = try secureFileManager.loadPhoto(filename: filename) - - // Verify no edited metadata is present - XCTAssertNil(loadedMetadata["isEdited"], "Photo should not have isEdited flag") - XCTAssertNil(loadedMetadata["originalFilename"], "Photo should not have originalFilename") - } - - /// Tests that multiple edited photos with different originals are tracked separately - /// Assertion: Each edited photo should maintain its own original filename link - func testSavePhoto_MultipleEditedPhotos_ShouldTrackSeparately() throws { - let filename1 = try secureFileManager.savePhoto( - testPhotoData, - withMetadata: [:], - isEdited: true, - originalFilename: "original_photo_1" - ) - - let filename2 = try secureFileManager.savePhoto( - testPhotoData, - withMetadata: [:], - isEdited: true, - originalFilename: "original_photo_2" - ) - - // Verify both photos were saved with unique filenames - XCTAssertNotEqual(filename1, filename2, "Filenames should be unique") - - // Verify first photo metadata - let (_, metadata1) = try secureFileManager.loadPhoto(filename: filename1) - XCTAssertTrue(metadata1["isEdited"] as? Bool == true) - XCTAssertEqual(metadata1["originalFilename"] as? String, "original_photo_1") - - // Verify second photo metadata - let (_, metadata2) = try secureFileManager.loadPhoto(filename: filename2) - XCTAssertTrue(metadata2["isEdited"] as? Bool == true) - XCTAssertEqual(metadata2["originalFilename"] as? String, "original_photo_2") - } - - // MARK: - Error Handling Tests - - /// Tests that file operations handle disk space issues gracefully - /// Assertion: Should propagate appropriate errors when disk operations fail - func testFileOperations_HandleDiskErrors() { - // Note: This test is difficult to implement without mocking FileManager - // In a real production app, you might use dependency injection to test this - - // For now, we'll test that our methods can handle empty data without crashing - XCTAssertNoThrow(try secureFileManager.savePhoto(Data()), - "Should handle empty data without crashing") - } - - // MARK: - Helper Methods - - /// Creates minimal JPEG test data for testing purposes - private func createTestJPEGData() -> Data { - // Create a minimal 1x1 pixel JPEG image for testing - let image = UIImage(systemName: "photo") ?? UIImage() - return image.jpegData(compressionQuality: 1.0) ?? Data() - } -} diff --git a/SnapSafeTests/SecureImageRepositoryTests.swift b/SnapSafeTests/SecureImageRepositoryTests.swift index 65321e5..9ba318e 100644 --- a/SnapSafeTests/SecureImageRepositoryTests.swift +++ b/SnapSafeTests/SecureImageRepositoryTests.swift @@ -13,521 +13,468 @@ import CoreLocation @MainActor final class SecureImageRepositoryTests: XCTestCase { - + // MARK: - Properties - + private var repository: SecureImageRepository! - private var mockThumbnailCache: FakeThumbnailCache! + private var thumbnailCache: ThumbnailCache! private var mockEncryptionScheme: FakeEncryptionScheme! private var tempDirectory: URL! private var galleryDirectory: URL! private var decoyDirectory: URL! - private var thumbnailsDirectory: URL! - + // MARK: - Setup & Teardown - + override func setUp() async throws { try await super.setUp() - + // Create temporary directory for testing tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - + // Set up subdirectories galleryDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.photosDir) decoyDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.decoysDir) - thumbnailsDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.thumbnailsDir) - + // Create mock dependencies - mockThumbnailCache = FakeThumbnailCache() + thumbnailCache = ThumbnailCache() mockEncryptionScheme = FakeEncryptionScheme() - + // Create repository with test directory - repository = TestableSecureImageRepository( - tempDirectory: tempDirectory, - thumbnailCache: mockThumbnailCache, - encryptionScheme: mockEncryptionScheme + repository = SecureImageRepository( + thumbnailCache: thumbnailCache, + encryptionScheme: mockEncryptionScheme, + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory ) } - + override func tearDown() async throws { // Clean up temp directory try? FileManager.default.removeItem(at: tempDirectory) - + repository = nil - mockThumbnailCache = nil + thumbnailCache = nil mockEncryptionScheme = nil tempDirectory = nil - + try await super.tearDown() } - + // MARK: - Directory Tests - - func testGetGalleryDirectoryReturnsCorrectDirectory() { + + func testGetGalleryDirectoryReturnsCorrectDirectory() async { // When - let galleryDir = repository.getGalleryDirectory() - + let galleryDir = await repository.getGalleryDirectory() + // Then XCTAssertEqual(galleryDir, galleryDirectory) } - - func testGetDecoyDirectoryReturnsCorrectDirectory() { + + func testGetDecoyDirectoryReturnsCorrectDirectory() async { // When - let decoyDir = repository.getDecoyDirectory() - + let decoyDir = await repository.getDecoyDirectory() + // Then XCTAssertEqual(decoyDir, decoyDirectory) } - + + /// Regression: hosted unit tests run inside the app's container, so any + /// directory that is NOT redirected to the temp directory points at the + /// real app's data. The destructive reset/poison-pill tests delete the + /// video-thumbnail directories, which would wipe real (unrecoverable) + /// thumbnails. Every directory the repository writes to must live under + /// the test temp directory. + func testAllDirectoriesAreIsolatedToTempDirectory() async { + let dirs: [(String, URL)] = [ + ("gallery", await repository.getGalleryDirectory()), + ("decoy", await repository.getDecoyDirectory()), + ("videos", await repository.getVideosDirectory()), + ("videoThumbnails", await repository.getVideoThumbnailsDirectory()), + ("decoyVideoThumbnails", await repository.getDecoyVideoThumbnailsDirectory()) + ] + for (name, dir) in dirs { + XCTAssertTrue( + dir.path.hasPrefix(tempDirectory.path), + "\(name) directory must be isolated to the test temp dir, got \(dir.path)" + ) + } + } + // MARK: - Security Tests - - func testEvictKeyCallsEncryptionScheme() { + + func testEvictKeyCallsEncryptionScheme() async { // When - repository.evictKey() - + await repository.evictKey() + // Then XCTAssertTrue(mockEncryptionScheme.evictKeyCalled) } - - func testSecurityFailureResetDeletesAllImagesAndEvictsKey() { + + func testSecurityFailureResetDeletesAllImagesAndEvictsKey() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photo1 = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let photo2 = galleryDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") - try! Data().write(to: photo1) - try! Data().write(to: photo2) - + try Data().write(to: photo1) + try Data().write(to: photo2) + // When - repository.securityFailureReset() - + await repository.securityFailureReset() + // Then - let photos = repository.getPhotos() + let photos = await repository.getPhotos() XCTAssertTrue(photos.isEmpty) XCTAssertTrue(mockEncryptionScheme.evictKeyCalled) } - - func testActivatePoisonPillDeletesNonDecoyImagesAndEvictsKey() { + + func testActivatePoisonPillDeletesNonDecoyImagesAndEvictsKey() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - try! FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) + // Create regular photos let photo1 = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let photo2 = galleryDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") - try! Data().write(to: photo1) - try! Data().write(to: photo2) - + try Data().write(to: photo1) + try Data().write(to: photo2) + // Create decoy let decoyContent = "decoy content".data(using: .utf8)! let decoyFile = decoyDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! decoyContent.write(to: decoyFile) - + try decoyContent.write(to: decoyFile) + // When - repository.activatePoisonPill() - + await repository.activatePoisonPill() + // Then - let photos = repository.getPhotos() + let photos = await repository.getPhotos() XCTAssertEqual(photos.count, 1) XCTAssertEqual(photos[0].photoName, "photo_20230101_120000_00.jpg") - + let targetFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") XCTAssertTrue(FileManager.default.fileExists(atPath: targetFile.path)) - - let restoredContent = try! Data(contentsOf: targetFile) + + let restoredContent = try Data(contentsOf: targetFile) XCTAssertEqual(restoredContent, decoyContent) - + XCTAssertTrue(mockEncryptionScheme.evictKeyCalled) } - + // MARK: - Photo Management Tests - - func testGetPhotosReturnsEmptyListWhenDirectoryDoesNotExist() { + + func testGetPhotosReturnsEmptyListWhenDirectoryDoesNotExist() async { // Given - gallery directory doesn't exist - + // When - let photos = repository.getPhotos() - + let photos = await repository.getPhotos() + // Then XCTAssertTrue(photos.isEmpty) } - - func testGetPhotosReturnsListOfPhotosWhenDirectoryExistsWithFiles() { + + func testGetPhotosReturnsListOfPhotosWhenDirectoryExistsWithFiles() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photo1 = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let photo2 = galleryDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") - try! Data().write(to: photo1) - try! Data().write(to: photo2) - + try Data().write(to: photo1) + try Data().write(to: photo2) + // When - let photos = repository.getPhotos() - + let photos = await repository.getPhotos() + // Then XCTAssertEqual(photos.count, 2) XCTAssertTrue(photos.contains { $0.photoName == "photo_20230101_120000_00.jpg" }) XCTAssertTrue(photos.contains { $0.photoName == "photo_20230101_120001_00.jpg" }) } - - func testGetPhotoByNameReturnsNullWhenDirectoryDoesNotExist() { - // Given - gallery directory doesn't exist - - // When - let photo = repository.getPhotoByName("photo_20230101_120000_00.jpg") - - // Then - XCTAssertNil(photo) - } - - func testGetPhotoByNameReturnsNullWhenPhotoDoesNotExist() { - // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - - // When - let photo = repository.getPhotoByName("photo_20230101_120000_00.jpg") - - // Then - XCTAssertNil(photo) - } - - func testGetPhotoByNameReturnsPhotoDefWhenPhotoExists() { - // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - - let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! Data().write(to: photoFile) - - // When - let photo = repository.getPhotoByName("photo_20230101_120000_00.jpg") - - // Then - XCTAssertNotNil(photo) - XCTAssertEqual(photo?.photoName, "photo_20230101_120000_00.jpg") - XCTAssertEqual(photo?.photoFormat, "jpg") - XCTAssertEqual(photo?.photoFile, photoFile) - } - - func testDeleteImageRemovesPhotoFileAndThumbnail() { + + func testDeleteImageRemovesPhotoFileAndThumbnail() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! Data().write(to: photoFile) - + try Data().write(to: photoFile) + let photoDef = PhotoDef( photoName: "photo_20230101_120000_00.jpg", photoFormat: "jpg", photoFile: photoFile ) - + // When - let result = repository.deleteImage(photoDef) - + let result = await repository.deleteImage(photoDef) + // Then XCTAssertTrue(result) XCTAssertFalse(FileManager.default.fileExists(atPath: photoFile.path)) - XCTAssertTrue(mockThumbnailCache.evictThumbnailCalled) } - - func testDeleteImageReturnsFalseWhenPhotoDoesNotExist() { + + func testDeleteImageReturnsFalseWhenPhotoDoesNotExist() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") // Don't create the file - + let photoDef = PhotoDef( photoName: "photo_20230101_120000_00.jpg", photoFormat: "jpg", photoFile: photoFile ) - + // When - let result = repository.deleteImage(photoDef) - + let result = await repository.deleteImage(photoDef) + // Then XCTAssertFalse(result) } - - func testDeleteAllImagesDeletesAllPhotos() { + + func testDeleteAllImagesDeletesAllPhotos() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photo1 = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let photo2 = galleryDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") - try! Data().write(to: photo1) - try! Data().write(to: photo2) - + try Data().write(to: photo1) + try Data().write(to: photo2) + // When - repository.deleteAllImages() - + await repository.deleteAllImages() + // Then - let photos = repository.getPhotos() + let photos = await repository.getPhotos() XCTAssertTrue(photos.isEmpty) } - + // MARK: - Decoy Tests - - func testIsDecoyPhotoReturnsTrueWhenDecoyExists() { + + func testIsDecoyPhotoReturnsTrueWhenDecoyExists() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - try! FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) + let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let decoyFile = decoyDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! Data().write(to: photoFile) - try! Data().write(to: decoyFile) - + try Data().write(to: photoFile) + try Data().write(to: decoyFile) + let photoDef = PhotoDef( photoName: "photo_20230101_120000_00.jpg", photoFormat: "jpg", photoFile: photoFile ) - + // When - let result = repository.isDecoyPhoto(photoDef) - + let result = await repository.isDecoyPhoto(photoDef) + // Then XCTAssertTrue(result) } - - func testIsDecoyPhotoReturnsFalseWhenDecoyDoesNotExist() { + + func testIsDecoyPhotoReturnsFalseWhenDecoyDoesNotExist() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! Data().write(to: photoFile) - + try Data().write(to: photoFile) + let photoDef = PhotoDef( photoName: "photo_20230101_120000_00.jpg", photoFormat: "jpg", photoFile: photoFile ) - + // When - let result = repository.isDecoyPhoto(photoDef) - + let result = await repository.isDecoyPhoto(photoDef) + // Then XCTAssertFalse(result) } - - func testNumDecoysReturnsCorrectCount() { + + func testNumDecoysReturnsCorrectCount() async throws { // Given - try! FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) + let decoy1 = decoyDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") let decoy2 = decoyDirectory.appendingPathComponent("photo_20230101_120001_00.jpg") - try! Data().write(to: decoy1) - try! Data().write(to: decoy2) - + try Data().write(to: decoy1) + try Data().write(to: decoy2) + // When - let count = repository.numDecoys() - + let count = await repository.numDecoys() + // Then XCTAssertEqual(count, 2) } - + func testAddDecoyPhotoWithKeyAddsPhotoToDecoysWhenUnderLimit() async throws { // Given - try! FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: galleryDirectory, withIntermediateDirectories: true) + let testImageData = createTestImageData() let photoFile = galleryDirectory.appendingPathComponent("photo_20230101_120000_00.jpg") - try! testImageData.write(to: photoFile) - + try testImageData.write(to: photoFile) + let photoDef = PhotoDef( photoName: "photo_20230101_120000_00.jpg", photoFormat: "jpg", photoFile: photoFile ) - + mockEncryptionScheme.decryptResult = testImageData - + // When let result = try await repository.addDecoyPhotoWithKey(photoDef, keyData: Data([0x00])) - + // Then XCTAssertTrue(result) XCTAssertTrue(mockEncryptionScheme.encryptWithKeyDataCalled) } - + func testAddDecoyPhotoReturnsFalseWhenAtLimit() async throws { // Given - try! FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) - + try FileManager.default.createDirectory(at: decoyDirectory, withIntermediateDirectories: true) + // Create max number of decoys - for i in 0.. UIImage { let size = CGSize(width: 100, height: 100) UIGraphicsBeginImageContext(size) @@ -537,29 +484,9 @@ final class SecureImageRepositoryTests: XCTestCase { UIGraphicsEndImageContext() return image } - + private func createTestImageData() -> Data { let image = createTestUIImage() return image.jpegData(compressionQuality: 1.0)! } } - -// MARK: - Testable Repository - -@MainActor -final class TestableSecureImageRepository: SecureImageRepository { - private let testDirectory: URL - - init(tempDirectory: URL, thumbnailCache: ThumbnailCache, encryptionScheme: EncryptionScheme) { - self.testDirectory = tempDirectory - super.init(thumbnailCache: thumbnailCache, encryptionScheme: encryptionScheme) - } - - override func getGalleryDirectory() -> URL { - return testDirectory.appendingPathComponent(SecureImageRepository.photosDir) - } - - override func getDecoyDirectory() -> URL { - return testDirectory.appendingPathComponent(SecureImageRepository.decoysDir) - } -} diff --git a/SnapSafeTests/SecurePhotoTests.swift b/SnapSafeTests/SecurePhotoTests.swift deleted file mode 100644 index c72bc3e..0000000 --- a/SnapSafeTests/SecurePhotoTests.swift +++ /dev/null @@ -1,660 +0,0 @@ -// -// SecurePhotoTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/25/25. -// - -import XCTest -import UIKit -@testable import SnapSafe - -class SecurePhotoTests: XCTestCase { - - private var testFileURL: URL! - private var testMetadata: [String: Any]! - private var testImage: UIImage! - private var securePhoto: SecurePhoto! - - override func setUp() { - super.setUp() - - // Create test file URL - testFileURL = URL(fileURLWithPath: "/tmp/test_photo.jpg") - - // Create test metadata - testMetadata = [ - "creationDate": Date().timeIntervalSince1970, - "imageWidth": 1920, - "imageHeight": 1080, - "isDecoy": false, - "originalOrientation": 1 - ] - - // Create test image - testImage = createTestImage() - - // Create test SecurePhoto instance - securePhoto = SecurePhoto( - filename: "test_photo_123", - metadata: testMetadata, - fileURL: testFileURL, - preloadedThumbnail: testImage - ) - } - - override func tearDown() { - securePhoto = nil - testImage = nil - testMetadata = nil - testFileURL = nil - super.tearDown() - } - - // MARK: - Initialization Tests - - /// Tests that SecurePhoto initializes with correct properties - /// Assertion: Should set all properties correctly during initialization - func testInit_SetsPropertiesCorrectly() { - let filename = "test_photo_456" - let metadata = ["testKey": "testValue"] - let fileURL = URL(fileURLWithPath: "/tmp/test.jpg") - let thumbnail = createTestImage() - - let photo = SecurePhoto( - filename: filename, - metadata: metadata, - fileURL: fileURL, - preloadedThumbnail: thumbnail - ) - - XCTAssertEqual(photo.filename, filename, "Filename should be set correctly") - XCTAssertEqual(photo.metadata["testKey"] as? String, "testValue", "Metadata should be preserved") - XCTAssertEqual(photo.fileURL, fileURL, "File URL should be set correctly") - XCTAssertNotNil(photo.id, "ID should be generated") - XCTAssertFalse(photo.isVisible, "Should initially be not visible") - } - - /// Tests that legacy initializer works correctly - /// Assertion: Should create SecurePhoto with provided images and metadata - func testLegacyInit_WorksCorrectly() { - let filename = "legacy_photo" - let thumbnail = createTestImage(size: CGSize(width: 100, height: 100)) - let fullImage = createTestImage(size: CGSize(width: 1000, height: 1000)) - let metadata = ["legacy": true] - - let photo = SecurePhoto(filename: filename, thumbnail: thumbnail, fullImage: fullImage, metadata: metadata) - - XCTAssertEqual(photo.filename, filename, "Filename should be set from legacy init") - XCTAssertEqual(photo.metadata["legacy"] as? Bool, true, "Metadata should be preserved") - } - - // MARK: - Equatable Tests - - /// Tests that SecurePhoto equality works correctly - /// Assertion: Should be equal when ID and filename match - func testEquatable_ComparesCorrectly() { - let photo1 = SecurePhoto(filename: "same_photo", metadata: [:], fileURL: testFileURL) - let photo2 = SecurePhoto(filename: "different_photo", metadata: [:], fileURL: testFileURL) - - // Same photo should equal itself - XCTAssertEqual(photo1, photo1, "Photo should equal itself") - - // Different photos should not be equal - XCTAssertNotEqual(photo1, photo2, "Different photos should not be equal") - } - - // MARK: - Decoy Status Tests - - /// Tests that isDecoy property reads from metadata correctly - /// Assertion: Should return false for non-decoy photos and true for decoy photos - func testIsDecoy_ReadsFromMetadataCorrectly() { - // Test false case - XCTAssertFalse(securePhoto.isDecoy, "Should return false when isDecoy is false in metadata") - - // Test true case - securePhoto.metadata["isDecoy"] = true - XCTAssertTrue(securePhoto.isDecoy, "Should return true when isDecoy is true in metadata") - - // Test missing key case - securePhoto.metadata.removeValue(forKey: "isDecoy") - XCTAssertFalse(securePhoto.isDecoy, "Should default to false when isDecoy key is missing") - } - - /// Tests that setDecoyStatus() updates metadata correctly - /// Assertion: Should update metadata with new decoy status - func testSetDecoyStatus_UpdatesMetadata() { - XCTAssertFalse(securePhoto.isDecoy, "Should initially be false") - - securePhoto.setDecoyStatus(true) - - XCTAssertTrue(securePhoto.isDecoy, "Should update to true") - XCTAssertEqual(securePhoto.metadata["isDecoy"] as? Bool, true, "Metadata should be updated") - - securePhoto.setDecoyStatus(false) - - XCTAssertFalse(securePhoto.isDecoy, "Should update back to false") - XCTAssertEqual(securePhoto.metadata["isDecoy"] as? Bool, false, "Metadata should be updated") - } - - // MARK: - Orientation Tests - - /// Tests that originalOrientation reads from metadata correctly - /// Assertion: Should convert EXIF orientation values to UIImage.Orientation correctly - func testOriginalOrientation_ReadsFromMetadata() { - let orientationTestCases: [(Int, UIImage.Orientation)] = [ - (1, .up), - (2, .upMirrored), - (3, .down), - (4, .downMirrored), - (5, .leftMirrored), - (6, .right), - (7, .rightMirrored), - (8, .left) - ] - - for (exifValue, expectedOrientation) in orientationTestCases { - securePhoto.metadata["originalOrientation"] = exifValue - XCTAssertEqual(securePhoto.originalOrientation, expectedOrientation, - "EXIF orientation \(exifValue) should map to \(expectedOrientation)") - } - } - - /// Tests that originalOrientation defaults correctly when metadata is missing - /// Assertion: Should default to .up when orientation metadata is missing - func testOriginalOrientation_DefaultsCorrectly() { - securePhoto.metadata.removeValue(forKey: "originalOrientation") - - XCTAssertEqual(securePhoto.originalOrientation, .up, "Should default to .up when orientation is missing") - } - - /// Tests that originalOrientation handles invalid values gracefully - /// Assertion: Should default to .up for invalid orientation values - func testOriginalOrientation_HandlesInvalidValues() { - // Test values outside valid range (1-8) - securePhoto.metadata["originalOrientation"] = 0 - XCTAssertEqual(securePhoto.originalOrientation, .up, "Should default to .up for orientation value 0") - - securePhoto.metadata["originalOrientation"] = 9 - XCTAssertEqual(securePhoto.originalOrientation, .up, "Should default to .up for orientation value 9") - - securePhoto.metadata["originalOrientation"] = -1 - XCTAssertEqual(securePhoto.originalOrientation, .up, "Should default to .up for negative orientation") - } - - /// Tests that originalOrientation reads from fullImage when metadata is missing - /// Assertion: Should inspect fullImage orientation when metadata unavailable - func testOriginalOrientation_ReadsFromFullImage() { - // Remove orientation metadata - securePhoto.metadata.removeValue(forKey: "originalOrientation") - - // Access originalOrientation which should trigger fullImage inspection - let orientation = securePhoto.originalOrientation - - // Should return a valid orientation (either from image or default) - let validOrientations: [UIImage.Orientation] = [.up, .down, .left, .right, .upMirrored, .downMirrored, .leftMirrored, .rightMirrored] - XCTAssertTrue(validOrientations.contains(orientation), "Should return valid orientation from fullImage or default") - } - - /// Tests that isLandscape property calculates correctly for different orientations - /// Assertion: Should determine landscape vs portrait correctly based on image dimensions and orientation - func testIsLandscape_CalculatesCorrectly() { - // Test cached value - securePhoto.metadata["isLandscape"] = true - XCTAssertTrue(securePhoto.isLandscape, "Should return cached landscape value") - - securePhoto.metadata["isLandscape"] = false - XCTAssertFalse(securePhoto.isLandscape, "Should return cached portrait value") - - // Remove cached value to test calculation - securePhoto.metadata.removeValue(forKey: "isLandscape") - - // Test normal orientation (1) with landscape image - securePhoto.metadata["originalOrientation"] = 1 - // Note: Since we can't easily control the test image dimensions in this context, - // we'll test that the property doesn't crash and returns a valid boolean - let isLandscape = securePhoto.isLandscape - XCTAssertTrue(isLandscape == true || isLandscape == false, "Should return valid boolean") - } - - /// Tests that frameSizeForDisplay calculates correct dimensions - /// Assertion: Should return appropriate width/height based on orientation and cell size - func testFrameSizeForDisplay_CalculatesCorrectDimensions() { - let cellSize: CGFloat = 100 - - // Test with normal orientation - securePhoto.metadata["originalOrientation"] = 1 - let (width, height) = securePhoto.frameSizeForDisplay(cellSize: cellSize) - - XCTAssertGreaterThan(width, 0, "Width should be positive") - XCTAssertGreaterThan(height, 0, "Height should be positive") - - // One dimension should equal cellSize for proper scaling - XCTAssertTrue(width == cellSize || height == cellSize, - "One dimension should equal cellSize for proper scaling") - } - - // MARK: - Memory Management Tests - - /// Tests that visibility tracking works correctly - /// Assertion: Should track visibility state changes - func testVisibilityTracking_WorksCorrectly() { - XCTAssertFalse(securePhoto.isVisible, "Should initially be not visible") - - securePhoto.isVisible = true - XCTAssertTrue(securePhoto.isVisible, "Should be visible when set") - - securePhoto.markAsInvisible() - XCTAssertFalse(securePhoto.isVisible, "Should be invisible after markAsInvisible()") - } - - /// Tests that access time tracking works correctly - /// Assertion: Should update last access time when images are accessed - func testAccessTimeTracking_UpdatesCorrectly() { - let initialAccessTime = securePhoto.timeSinceLastAccess - - // Wait a small amount to ensure time difference - Thread.sleep(forTimeInterval: 0.01) - - // Access thumbnail to update access time - let _ = securePhoto.thumbnail - - let newAccessTime = securePhoto.timeSinceLastAccess - XCTAssertLessThan(newAccessTime, initialAccessTime, - "Access time should be updated when thumbnail is accessed") - } - - /// Tests that clearMemory works correctly - /// Assertion: Should clear cached images while optionally keeping thumbnail - func testClearMemory_WorksCorrectly() { - // Preload images by accessing them - let _ = securePhoto.thumbnail - let _ = securePhoto.fullImage - - // Clear memory keeping thumbnail - securePhoto.clearMemory(keepThumbnail: true) - - // Test that we can still access thumbnail (it should be cached) - let thumbnailAfterClear = securePhoto.thumbnail - XCTAssertNotNil(thumbnailAfterClear, "Thumbnail should still be available when keepThumbnail is true") - - // Clear all memory - securePhoto.clearMemory(keepThumbnail: false) - - // Images should still be accessible (will be reloaded), but this tests the clearing mechanism - let thumbnailAfterFullClear = securePhoto.thumbnail - XCTAssertNotNil(thumbnailAfterFullClear, "Thumbnail should be reloadable after full clear") - } - - // MARK: - Image Loading Tests - - /// Tests that thumbnail loading works with preloaded image - /// Assertion: Should return preloaded thumbnail when available - func testThumbnailLoading_WorksWithPreloadedImage() { - let thumbnail = securePhoto.thumbnail - - XCTAssertNotNil(thumbnail, "Should return valid thumbnail") - XCTAssertTrue(securePhoto.isVisible, "Should mark as visible when thumbnail is accessed") - } - - /// Tests that thumbnail loading handles missing files gracefully - /// Assertion: Should return placeholder image when file cannot be loaded - func testThumbnailLoading_HandlesMissingFiles() { - // Create photo with non-existent file - let missingPhoto = SecurePhoto( - filename: "missing_photo", - metadata: [:], - fileURL: URL(fileURLWithPath: "/nonexistent/path.jpg") - ) - - let thumbnail = missingPhoto.thumbnail - - XCTAssertNotNil(thumbnail, "Should return placeholder for missing file") - // Should be a system image placeholder - XCTAssertNotNil(UIImage(systemName: "photo"), "Placeholder should be available") - } - - /// Tests that fullImage loading handles missing files gracefully - /// Assertion: Should fallback to thumbnail when full image cannot be loaded - func testFullImageLoading_HandlesMissingFiles() { - // Create photo with non-existent file - let missingPhoto = SecurePhoto( - filename: "missing_full_photo", - metadata: [:], - fileURL: URL(fileURLWithPath: "/nonexistent/path.jpg") - ) - - let fullImage = missingPhoto.fullImage - - XCTAssertNotNil(fullImage, "Should return fallback image for missing full image") - XCTAssertTrue(missingPhoto.isVisible, "Should mark as visible when fullImage is accessed") - } - - // MARK: - Metadata Persistence Tests - - /// Tests that setDecoyStatus performs async metadata save - /// Assertion: Should handle metadata saving asynchronously without blocking - func testSetDecoyStatus_PerformsAsyncSave() { - let expectation = XCTestExpectation(description: "Decoy status should be set without blocking") - - // Set decoy status (this triggers async save) - securePhoto.setDecoyStatus(true) - - // Should complete immediately (async operation) - XCTAssertTrue(securePhoto.isDecoy, "Decoy status should be updated immediately") - - // Give async operation time to complete - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - } - - // MARK: - Edge Cases Tests - - /// Tests that SecurePhoto handles nil or empty metadata gracefully - /// Assertion: Should work correctly with minimal or missing metadata - func testHandlesEmptyMetadata_Gracefully() { - let photoWithEmptyMetadata = SecurePhoto( - filename: "empty_metadata_photo", - metadata: [:], - fileURL: testFileURL - ) - - XCTAssertFalse(photoWithEmptyMetadata.isDecoy, "Should default decoy to false") - XCTAssertEqual(photoWithEmptyMetadata.originalOrientation, .up, "Should default orientation to up") - XCTAssertNotNil(photoWithEmptyMetadata.thumbnail, "Should provide thumbnail even with empty metadata") - } - - /// Tests that SecurePhoto handles invalid metadata types gracefully - /// Assertion: Should handle type mismatches in metadata without crashing - func testHandlesInvalidMetadataTypes_Gracefully() { - let invalidMetadata: [String: Any] = [ - "isDecoy": "not_a_boolean", // Wrong type - "originalOrientation": "not_an_int", // Wrong type - "isLandscape": 123 // Wrong type - ] - - let photoWithInvalidMetadata = SecurePhoto( - filename: "invalid_metadata_photo", - metadata: invalidMetadata, - fileURL: testFileURL - ) - - // Should handle gracefully and use defaults - XCTAssertFalse(photoWithInvalidMetadata.isDecoy, "Should default to false for invalid decoy type") - XCTAssertEqual(photoWithInvalidMetadata.originalOrientation, .up, "Should default to up for invalid orientation") - } - - /// Tests that memory operations work with concurrent access - /// Assertion: Should handle concurrent memory operations safely - func testConcurrentMemoryOperations_WorkSafely() { - let expectation = XCTestExpectation(description: "Concurrent operations should complete safely") - expectation.expectedFulfillmentCount = 3 - - // Simulate concurrent access from different threads - DispatchQueue.global(qos: .userInitiated).async { - let _ = self.securePhoto.thumbnail - expectation.fulfill() - } - - DispatchQueue.global(qos: .userInitiated).async { - let _ = self.securePhoto.fullImage - expectation.fulfill() - } - - DispatchQueue.global(qos: .userInitiated).async { - self.securePhoto.clearMemory(keepThumbnail: false) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 3.0) - } - - /// Tests that timeSinceLastAccess increases over time - /// Assertion: Should track time accurately - func testTimeSinceLastAccess_IncreasesOverTime() { - // Access the thumbnail to set last access time - let _ = securePhoto.thumbnail - - let initialTime = securePhoto.timeSinceLastAccess - - // Wait a short time - Thread.sleep(forTimeInterval: 0.05) - - let laterTime = securePhoto.timeSinceLastAccess - - XCTAssertGreaterThan(laterTime, initialTime, "Time since last access should increase over time") - } - - /// Tests isLandscape calculation for rotated orientations (5-8) - /// Assertion: Should handle rotated orientations correctly by swapping width/height comparison - func testIsLandscape_HandlesRotatedOrientations() { - // Test rotated orientations (5-8) which swap width/height for landscape calculation - let rotatedOrientations = [5, 6, 7, 8] - - for orientation in rotatedOrientations { - let rotatedPhoto = SecurePhoto( - filename: "rotated_test_\(orientation)", - metadata: ["originalOrientation": orientation], - fileURL: testFileURL, - preloadedThumbnail: testImage - ) - - let isLandscape = rotatedPhoto.isLandscape - XCTAssertTrue(isLandscape == true || isLandscape == false, - "Should calculate valid landscape value for rotated orientation \(orientation)") - } - } - - /// Tests frameSizeForDisplay with different orientation combinations - /// Assertion: Should calculate different dimensions for different orientation/landscape combinations - func testFrameSizeForDisplay_HandlesOrientationCombinations() { - let cellSize: CGFloat = 100 - - // Test case 1: Landscape photo, normal orientation (should use landscape branch) - let landscapePhoto = SecurePhoto( - filename: "landscape_test", - metadata: ["isLandscape": true, "originalOrientation": 1], - fileURL: testFileURL, - preloadedThumbnail: testImage - ) - let (landscapeWidth, _) = landscapePhoto.frameSizeForDisplay(cellSize: cellSize) - XCTAssertEqual(landscapeWidth, cellSize, "Landscape normal orientation should use cellSize for width") - - // Test case 2: Portrait photo, normal orientation (should use portrait branch) - let portraitPhoto = SecurePhoto( - filename: "portrait_test", - metadata: ["isLandscape": false, "originalOrientation": 1], - fileURL: testFileURL, - preloadedThumbnail: testImage - ) - let (_, portraitHeight) = portraitPhoto.frameSizeForDisplay(cellSize: cellSize) - XCTAssertEqual(portraitHeight, cellSize, "Portrait normal orientation should use cellSize for height") - } - - /// Tests setDecoyStatus error handling - /// Assertion: Should handle file system errors gracefully - func testSetDecoyStatus_HandlesErrors() { - // Create photo with invalid file path to trigger error conditions - let invalidPhoto = SecurePhoto( - filename: "invalid_path_photo", - metadata: [:], - fileURL: URL(fileURLWithPath: "/invalid/readonly/path.jpg") - ) - - // Should not crash even if metadata save fails - XCTAssertNoThrow(invalidPhoto.setDecoyStatus(true), - "Should handle metadata save errors gracefully") - - // Metadata should still be updated in memory even if disk save fails - XCTAssertTrue(invalidPhoto.isDecoy, "Should update in-memory metadata even if disk save fails") - } - - /// Tests clearMemory edge cases - /// Assertion: Should handle cases where images are not loaded - func testClearMemory_HandlesEdgeCases() { - // Test clearing memory when no images are loaded - let freshPhoto = SecurePhoto( - filename: "fresh_photo", - metadata: [:], - fileURL: testFileURL - ) - - // Should not crash when clearing memory of unloaded images - XCTAssertNoThrow(freshPhoto.clearMemory(keepThumbnail: true), - "Should not crash when clearing unloaded images") - XCTAssertNoThrow(freshPhoto.clearMemory(keepThumbnail: false), - "Should not crash when clearing unloaded images") - } - - /// Tests handling of nil metadata values - /// Assertion: Should handle nil values in metadata dictionary - func testHandlesNilMetadataValues_Gracefully() { - var metadataWithNils: [String: Any] = [:] - metadataWithNils["isDecoy"] = nil - metadataWithNils["originalOrientation"] = nil - metadataWithNils["isLandscape"] = nil - - let photoWithNils = SecurePhoto( - filename: "nil_metadata_photo", - metadata: metadataWithNils, - fileURL: testFileURL - ) - - // Should handle nil values gracefully - XCTAssertFalse(photoWithNils.isDecoy, "Should default to false for nil decoy value") - XCTAssertEqual(photoWithNils.originalOrientation, .up, "Should default to up for nil orientation") - } - - /// Tests fullImage fallback behavior - /// Assertion: Should fallback to thumbnail when fullImage loading fails - func testFullImage_FallbackBehavior() { - // Create photo that will fail to load full image - let failingPhoto = SecurePhoto( - filename: "failing_photo", - metadata: [:], - fileURL: URL(fileURLWithPath: "/nonexistent/fail.jpg"), - preloadedThumbnail: testImage - ) - - let fullImage = failingPhoto.fullImage - - // Should fallback to thumbnail (which is preloaded) - XCTAssertNotNil(fullImage, "Should return fallback image when full image fails to load") - XCTAssertTrue(failingPhoto.isVisible, "Should mark as visible even when using fallback") - } - - /// Tests thumbnail placeholder behavior - /// Assertion: Should return system placeholder when thumbnail cannot be loaded - func testThumbnail_PlaceholderBehavior() { - // Create photo with no preloaded thumbnail and invalid file path - let placeholderPhoto = SecurePhoto( - filename: "placeholder_photo", - metadata: [:], - fileURL: URL(fileURLWithPath: "/invalid/placeholder.jpg") - ) - - let thumbnail = placeholderPhoto.thumbnail - - // Should return placeholder (system photo icon) - XCTAssertNotNil(thumbnail, "Should return placeholder thumbnail") - XCTAssertTrue(placeholderPhoto.isVisible, "Should mark as visible when accessing placeholder") - } - - /// Tests that both thumbnail and fullImage access update lastAccessTime - /// Assertion: Should update access time for both image types - func testLastAccessTime_UpdatesForBothImageTypes() { - // Use the existing securePhoto with preloaded thumbnail for consistent behavior - let initialTime = securePhoto.timeSinceLastAccess - - // Wait to ensure measurable time difference - Thread.sleep(forTimeInterval: 0.1) - - // Access thumbnail should update access time - let _ = securePhoto.thumbnail - let timeAfterThumbnail = securePhoto.timeSinceLastAccess - - XCTAssertLessThan(timeAfterThumbnail, initialTime, "Thumbnail access should update last access time") - XCTAssertLessThan(timeAfterThumbnail, 0.05, "Thumbnail access should result in very recent access time") - - // Wait longer to ensure measurable time difference - Thread.sleep(forTimeInterval: 0.1) - - // Access full image should update access time again - let _ = securePhoto.fullImage - let timeAfterFullImage = securePhoto.timeSinceLastAccess - - // Verify both operations update the timestamp correctly - XCTAssertLessThan(timeAfterFullImage, 0.05, "Full image access should result in very recent access time") - XCTAssertLessThan(timeAfterFullImage, initialTime, "Full image access should update last access time") - - // Verify the access operations work independently - XCTAssertTrue(securePhoto.isVisible, "Photo should be marked as visible after image access") - } - - /// Tests image caching behavior - /// Assertion: Should cache images after first load and reuse them - func testImageCaching_WorksCorrectly() { - // First thumbnail access should load and cache - let firstThumbnail = securePhoto.thumbnail - - // Second access should use cached version (same instance) - let secondThumbnail = securePhoto.thumbnail - - // Both should be the same cached instance - XCTAssertTrue(firstThumbnail === secondThumbnail, "Should reuse cached thumbnail") - - // Same test for full image - let firstFullImage = securePhoto.fullImage - let secondFullImage = securePhoto.fullImage - - XCTAssertTrue(firstFullImage === secondFullImage, "Should reuse cached full image") - } - - /// Tests concurrent metadata operations - /// Assertion: Should handle concurrent metadata updates safely - func testConcurrentMetadataOperations_WorkSafely() { - let expectation = XCTestExpectation(description: "Concurrent metadata operations should complete safely") - expectation.expectedFulfillmentCount = 4 - - // Simulate concurrent metadata access and updates - DispatchQueue.global(qos: .userInitiated).async { - let _ = self.securePhoto.isDecoy - expectation.fulfill() - } - - DispatchQueue.global(qos: .userInitiated).async { - let _ = self.securePhoto.originalOrientation - expectation.fulfill() - } - - DispatchQueue.global(qos: .userInitiated).async { - self.securePhoto.setDecoyStatus(true) - expectation.fulfill() - } - - DispatchQueue.global(qos: .userInitiated).async { - let _ = self.securePhoto.isLandscape - expectation.fulfill() - } - - wait(for: [expectation], timeout: 3.0) - } - - // MARK: - Helper Methods - - /// Creates a test image for use in tests - private func createTestImage(size: CGSize = CGSize(width: 200, height: 200)) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { context in - context.cgContext.setFillColor(UIColor.blue.cgColor) - context.cgContext.fill(CGRect(origin: .zero, size: size)) - - context.cgContext.setFillColor(UIColor.white.cgColor) - context.cgContext.fillEllipse(in: CGRect(x: size.width * 0.25, y: size.height * 0.25, - width: size.width * 0.5, height: size.height * 0.5)) - } - } -} diff --git a/SnapSafeTests/SnapSafeTests.swift b/SnapSafeTests/SnapSafeTests.swift deleted file mode 100644 index 5df927b..0000000 --- a/SnapSafeTests/SnapSafeTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Snap_SafeTests.swift -// SnapSafeTests -// -// Created by Bill Booth on 5/2/25. -// - -import XCTest -@testable import SnapSafe - -/// Basic test class to verify test target is working -class SnapSafeTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - XCTAssertTrue(true, "Basic test should pass") - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - let _ = Array(0...1000).map { $0 * 2 } - } - } -} diff --git a/SnapSafeTests/TestUtils.swift b/SnapSafeTests/TestUtils.swift index 1c8dcf5..bce1c46 100644 --- a/SnapSafeTests/TestUtils.swift +++ b/SnapSafeTests/TestUtils.swift @@ -30,34 +30,38 @@ func XCTAssertTrueAsync( XCTAssertTrue(value, message(), file: file, line: line) } -func XCTAssertEqualAsync( - _ lhs: @autoclosure () async throws -> T, - _ rhs: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) async rethrows { - let lv = try await lhs() - let rv = try await rhs() - XCTAssertEqual(lv, rv, message(), file: file, line: line) -} +final class TestClock: Clock { + private var _fixed: Date + private var _monotonic: TimeInterval -func XCTAssertGreaterThanAsync( - _ expression: @autoclosure () async throws -> T, - _ expected: @autoclosure () -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line -) async rethrows { - let value = try await expression() - XCTAssertGreaterThan(value, expected(), message(), file: file, line: line) -} + init(_ start: Date = Date(timeIntervalSince1970: 1)) { + self._fixed = start + self._monotonic = 0 + } -final class TestClock: Clock { - var fixed: Date - init(_ start: Date = Date(timeIntervalSince1970: 1)) { self.fixed = start } - var now: Date { fixed } - func advance(by seconds: TimeInterval) { fixed.addTimeInterval(seconds) } + var fixed: Date { + get { _fixed } + set { + _monotonic += newValue.timeIntervalSince(_fixed) + _fixed = newValue + } + } + + var now: Date { _fixed } + var monotonicNow: TimeInterval { _monotonic } + + func advance(by seconds: TimeInterval) { + _fixed.addTimeInterval(seconds) + _monotonic += seconds + } + + func advanceWallOnly(by seconds: TimeInterval) { + _fixed.addTimeInterval(seconds) + } + + func advanceMonotonicOnly(by seconds: TimeInterval) { + _monotonic += seconds + } } extension Publisher where Failure == Never { diff --git a/SnapSafeTests/Util/FakeEncryptionScheme.swift b/SnapSafeTests/Util/FakeEncryptionScheme.swift index f2ad02e..ea36315 100644 --- a/SnapSafeTests/Util/FakeEncryptionScheme.swift +++ b/SnapSafeTests/Util/FakeEncryptionScheme.swift @@ -27,6 +27,7 @@ final class FakeEncryptionScheme: EncryptionScheme { try plain.write(to: targetFile) } + // periphery:ignore func encrypt(plain: Data, keyBytes: Data) async throws -> Data { return plain // Return plain data for testing } @@ -56,7 +57,7 @@ final class FakeEncryptionScheme: EncryptionScheme { return Data(count: 32) // Return dummy key } - func evictKey() { + func evictKey() async { evictKeyCalled = true } @@ -64,7 +65,7 @@ final class FakeEncryptionScheme: EncryptionScheme { // No-op for testing } - func securityFailureReset() async throws { + func securityFailureReset() async { // No-op for testing } diff --git a/SnapSafeTests/Util/FakeThumbnailCache.swift b/SnapSafeTests/Util/FakeThumbnailCache.swift deleted file mode 100644 index f76099a..0000000 --- a/SnapSafeTests/Util/FakeThumbnailCache.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// FakeThumbnailCache.swift -// SnapSafe -// -// Created by Adam Brown on 9/7/25. -// - -@testable import SnapSafe -import UIKit - -@MainActor -final class FakeThumbnailCache: ThumbnailCache { - var mockThumbnail: UIImage? - var getThumbnailCalled = false - var putThumbnailCalled = false - var evictThumbnailCalled = false - var clearCalled = false - - override func getThumbnail(_ photoDef: PhotoDef) -> UIImage? { - getThumbnailCalled = true - return mockThumbnail - } - - override func putThumbnail(_ photoDef: PhotoDef, _ image: UIImage) { - putThumbnailCalled = true - } - - override func evictThumbnail(_ photoDef: PhotoDef) { - evictThumbnailCalled = true - } - - override func clear() { - clearCalled = true - } -} diff --git a/SnapSafeTests/Util/FakeVideoEncryptionService.swift b/SnapSafeTests/Util/FakeVideoEncryptionService.swift new file mode 100644 index 0000000..52b1d7f --- /dev/null +++ b/SnapSafeTests/Util/FakeVideoEncryptionService.swift @@ -0,0 +1,62 @@ +// +// FakeVideoEncryptionService.swift +// SnapSafeTests +// +// Minimal fake that simulates SECV encrypt/decrypt by writing marker files, +// so decoy-video logic can be tested without real video crypto. +// + +import Foundation +import Combine +import CryptoKit +@testable import SnapSafe + +@MainActor +final class FakeVideoEncryptionService: VideoEncryptionServiceProtocol { + + static let decryptedMarker = Data("plaintext".utf8) + static let reEncryptedMarker = Data("decoy-reencrypted".utf8) + + private(set) var decryptForSharingCalled = false + private(set) var encryptForDecoyCalled = false + + // periphery:ignore + func encryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) { + (Empty().eraseToAnyPublisher(), { _ in }) + } + + // periphery:ignore + func decryptVideo(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) -> (progress: AnyPublisher, completion: (Result) -> Void) { + (Empty().eraseToAnyPublisher(), { _ in }) + } + + func decryptVideoForSharing(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws { + decryptForSharingCalled = true + try requireExistingOutput(outputURL) + try Self.decryptedMarker.write(to: outputURL) + } + + func encryptVideoForDecoy(inputURL: URL, outputURL: URL, encryptionKey: SymmetricKey) async throws { + encryptForDecoyCalled = true + try requireExistingOutput(outputURL) + try Self.reEncryptedMarker.write(to: outputURL) + } + + /// The real `VideoEncryptionService` opens its output with + /// `FileHandle(forWritingTo:)`, which requires the file to already exist. + /// Model that precondition so tests catch callers that forget to pre-create + /// the output file. + private func requireExistingOutput(_ outputURL: URL) throws { + guard FileManager.default.fileExists(atPath: outputURL.path) else { + throw NSError( + domain: "FakeVideoEncryptionService", + code: 1, + userInfo: [NSLocalizedDescriptionKey: + "output file must exist before writing: \(outputURL.lastPathComponent)"] + ) + } + } + + // periphery:ignore + func validateSECVFile(fileURL: URL) -> Bool { true } +} diff --git a/SnapSafeTests/VerifyPinUseCaseTests.swift b/SnapSafeTests/VerifyPinUseCaseTests.swift index a31472a..47414ee 100644 --- a/SnapSafeTests/VerifyPinUseCaseTests.swift +++ b/SnapSafeTests/VerifyPinUseCaseTests.swift @@ -6,54 +6,214 @@ // import XCTest +import FactoryKit +import Mockable @testable import SnapSafe +private enum TestError: Error, Equatable { + case transient +} + +@MainActor final class VerifyPinUseCaseTests: XCTestCase { - + + func test_verifyPin_returnsRetryableFailure_whenKeyDerivationThrows() async throws { + let pin = "1234" + let hashedPin = HashedPin(hash: "h", salt: "s") + + let pinRepo = MockPinRepository() + given(pinRepo).hasPoisonPillPin().willReturn(false) + given(pinRepo).verifyPoisonPillPin(.value(pin)).willReturn(false) + given(pinRepo).getHashedPin().willReturn(hashedPin) + given(pinRepo).verifySecurityPin(.value(pin)).willReturn(true) + + let settings = MockSettingsDataSource() + given(settings).setFailedPinAttempts(.value(0)).willReturn() + given(settings).setLastFailedAttemptTimestamp(.value(0)).willReturn() + + let throwingScheme = MockEncryptionScheme() + given(throwingScheme) + .deriveAndCacheKey(plainPin: .value(pin), hashedPin: .value(hashedPin)) + .willThrow(TestError.transient) + + let passthrough = PassThroughEncryptionScheme() + let authRepo = AuthorizationRepository( + settings: settings, + encryptionScheme: passthrough, + clock: SystemClock() + ) + let imageRepo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: passthrough + ) + let authorizePinUseCase = AuthorizePinUseCase( + authRepository: authRepo, + pinRepository: pinRepo + ) + + let sut = VerifyPinUseCase( + authRepository: authRepo, + imageRepository: imageRepo, + pinRepository: pinRepo, + encryptionScheme: throwingScheme, + authorizePinUseCase: authorizePinUseCase + ) + + let result = await sut.verifyPin(pin) + + switch result { + case .failure(let error): + XCTAssertEqual(error as? TestError, .transient) + case .success, .invalidPin: + XCTFail("Expected .failure(.transient), got \(result)") + } + } + + func test_verifyPin_onInvalidPin_incrementsCounterExactlyOnce_andReturnsNewCount() async throws { + // M1: the failed-attempt counter must have a single writer (the + // repository, via incrementFailedAttempts). The use case returns the + // authoritative new count so the view model never writes it a second + // time from stale local state. + let pin = "1234" + + let pinRepo = MockPinRepository() + given(pinRepo).hasPoisonPillPin().willReturn(false) + given(pinRepo).verifyPoisonPillPin(.value(pin)).willReturn(false) + given(pinRepo).getHashedPin().willReturn(nil) + given(pinRepo).verifySecurityPin(.value(pin)).willReturn(false) + + // Real settings-backed repository so the persisted counter is the + // single source of truth. + let settings = UserDefaultsSettingsDataSource(userDefaults: .inMemoryForTesting()) + let passthrough = PassThroughEncryptionScheme() + let authRepo = AuthorizationRepository( + settings: settings, + encryptionScheme: passthrough, + clock: SystemClock() + ) + let imageRepo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: passthrough + ) + let authorizePinUseCase = AuthorizePinUseCase( + authRepository: authRepo, + pinRepository: pinRepo + ) + + let sut = VerifyPinUseCase( + authRepository: authRepo, + imageRepository: imageRepo, + pinRepository: pinRepo, + encryptionScheme: passthrough, + authorizePinUseCase: authorizePinUseCase + ) + + let result = await sut.verifyPin(pin) + + // Persisted counter incremented exactly once (0 -> 1). + let persisted = await settings.getFailedPinAttempts() + XCTAssertEqual(persisted, 1, "Invalid PIN must increment the counter exactly once") + + // Result carries the authoritative new count, sourced from the single + // increment — the caller must not derive it from stale local state. + guard case .invalidPin(let count) = result else { + return XCTFail("Expected .invalidPin, got \(result)") + } + XCTAssertEqual(count, 1, "Result must carry the post-increment count") + } + + func test_verifyPin_doesNotInvokePoisonPillVerify_whenNoPoisonPillIsSet() async throws { + // H5: when hasPoisonPillPin() is false, verifyPoisonPillPin must be + // short-circuited so we don't run a second Argon2 verification per + // attempt and don't leak a timing oracle about poison-pill presence. + let pin = "1234" + let hashedPin = HashedPin(hash: "h", salt: "s") + + let pinRepo = MockPinRepository() + given(pinRepo).hasPoisonPillPin().willReturn(false) + given(pinRepo).verifyPoisonPillPin(.value(pin)).willReturn(false) + given(pinRepo).getHashedPin().willReturn(hashedPin) + given(pinRepo).verifySecurityPin(.value(pin)).willReturn(true) + + let settings = MockSettingsDataSource() + given(settings).setFailedPinAttempts(.value(0)).willReturn() + given(settings).setLastFailedAttemptTimestamp(.value(0)).willReturn() + + let scheme = MockEncryptionScheme() + given(scheme) + .deriveAndCacheKey(plainPin: .value(pin), hashedPin: .value(hashedPin)) + .willReturn() + + let passthrough = PassThroughEncryptionScheme() + let authRepo = AuthorizationRepository( + settings: settings, + encryptionScheme: passthrough, + clock: SystemClock() + ) + let imageRepo = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: passthrough + ) + let authorizePinUseCase = AuthorizePinUseCase( + authRepository: authRepo, + pinRepository: pinRepo + ) + + let sut = VerifyPinUseCase( + authRepository: authRepo, + imageRepository: imageRepo, + pinRepository: pinRepo, + encryptionScheme: scheme, + authorizePinUseCase: authorizePinUseCase + ) + + _ = await sut.verifyPin(pin) + + verify(pinRepo).hasPoisonPillPin().called(.once) + verify(pinRepo).verifyPoisonPillPin(.value(pin)).called(.never) + } + func testVerifyPinUseCaseCreation() throws { // Test that the use case can be created with all dependencies // This is a basic smoke test to ensure the class is properly structured - + let authManager = AuthorizationRepository( settings: UserDefaultsSettingsDataSource(), encryptionScheme: PassThroughEncryptionScheme(), clock: SystemClock() ) - + let imageManager = SecureImageRepository( thumbnailCache: ThumbnailCache(), encryptionScheme: PassThroughEncryptionScheme() ) - + let pinRepository = PinRepositoryImpl( dataSource: UserDefaultsSettingsDataSource(), encryptionScheme: PassThroughEncryptionScheme(), deviceInfo: DeviceInfoDataSourceImpl(), pinCrypto: PinCryptoImpl() ) - - let encryptionScheme = PassThroughEncryptionScheme() - + let authorizePinUseCase = AuthorizePinUseCase( authRepository: authManager, - pinRepository: pinRepository, - encryptionScheme: encryptionScheme + pinRepository: pinRepository ) - + let verifyPinUseCase = VerifyPinUseCase( - authManager: authManager, - imageManager: imageManager, + authRepository: authManager, + imageRepository: imageManager, pinRepository: pinRepository, - encryptionScheme: encryptionScheme, + encryptionScheme: PassThroughEncryptionScheme(), authorizePinUseCase: authorizePinUseCase ) - + XCTAssertNotNil(verifyPinUseCase) } - + func testVerifyPinUseCaseIntegrationWithDI() throws { // Test that the use case can be created via dependency injection let verifyPinUseCase = Container.shared.verifyPinUseCase() XCTAssertNotNil(verifyPinUseCase) } -} \ No newline at end of file +} diff --git a/SnapSafeTests/VideoImportTests.swift b/SnapSafeTests/VideoImportTests.swift new file mode 100644 index 0000000..fa5fe86 --- /dev/null +++ b/SnapSafeTests/VideoImportTests.swift @@ -0,0 +1,109 @@ +// +// VideoImportTests.swift +// SnapSafeTests +// +// Covers SecureImageRepository.importVideo: a picked video is encrypted to a +// uniquely-named, date-sortable SECV file in the videos directory, with a +// thumbnail when the input is a real video. +// + +import XCTest +import CryptoKit +@testable import SnapSafe + +@MainActor +final class VideoImportTests: XCTestCase { + + private var repository: SecureImageRepository! + private var tempDirectory: URL! + private var videosDirectory: URL! + private var videoThumbnailsDirectory: URL! + + override func setUp() async throws { + try await super.setUp() + + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + videosDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videosDir) + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + videoThumbnailsDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videoThumbnailsDir) + + repository = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: FakeVideoEncryptionService(), + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: tempDirectory) + repository = nil + tempDirectory = nil + videosDirectory = nil + videoThumbnailsDirectory = nil + try await super.tearDown() + } + + func testImportVideoCreatesEncryptedSecvInVideosDirectory() async throws { + let plain = try makePlaintext() + + let ok = await repository.importVideo(from: plain) + XCTAssertTrue(ok) + + let files = try secvFiles() + XCTAssertEqual(files.count, 1) + XCTAssertTrue(files[0].lastPathComponent.hasPrefix("video_"), + "Imported video should use the video_ naming convention") + XCTAssertGreaterThan(try Data(contentsOf: files[0]).count, 0, + "Imported video should be written (encrypted) to disk") + } + + func testImportedVideoNameIsDateSortable() async throws { + _ = await repository.importVideo(from: try makePlaintext()) + + let file = try XCTUnwrap(try secvFiles().first) + let name = file.deletingPathExtension().lastPathComponent + let videoDef = VideoDef(videoName: name, videoFormat: "secv", videoFile: file) + XCTAssertNotNil(videoDef.dateTaken(), + "Imported video name must be parseable by dateTaken() so it sorts in the gallery") + } + + func testImportingTwoVideosCreatesTwoDistinctFiles() async throws { + _ = await repository.importVideo(from: try makePlaintext()) + _ = await repository.importVideo(from: try makePlaintext()) + + let files = try secvFiles() + XCTAssertEqual(files.count, 2, "Each imported video should get its own file") + XCTAssertEqual(Set(files.map { $0.lastPathComponent }).count, 2, + "Imported videos must have unique names even within the same second") + } + + func testImportVideoWithNonVideoInputStillEncryptsButSkipsThumbnail() async throws { + // The fake encryption service ignores the input format, but thumbnail + // generation uses real AVFoundation, which can't decode arbitrary bytes. + let ok = await repository.importVideo(from: try makePlaintext()) + XCTAssertTrue(ok, "Encryption should succeed even when thumbnail generation fails") + + let thumbs = (try? FileManager.default.contentsOfDirectory( + at: videoThumbnailsDirectory, includingPropertiesForKeys: nil)) ?? [] + XCTAssertTrue(thumbs.filter { $0.pathExtension == "jpg" }.isEmpty, + "A non-decodable input should not produce a thumbnail") + } + + // MARK: - Helpers + + private func makePlaintext() throws -> URL { + let url = tempDirectory.appendingPathComponent("\(UUID().uuidString).mov") + try Data((0..<2048).map { UInt8($0 & 0xFF) }).write(to: url) + return url + } + + private func secvFiles() throws -> [URL] { + try FileManager.default + .contentsOfDirectory(at: videosDirectory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "secv" } + } +} diff --git a/SnapSafeTests/VideoMetaDataFormattingTests.swift b/SnapSafeTests/VideoMetaDataFormattingTests.swift new file mode 100644 index 0000000..e7abcb5 --- /dev/null +++ b/SnapSafeTests/VideoMetaDataFormattingTests.swift @@ -0,0 +1,74 @@ +// +// VideoMetaDataFormattingTests.swift +// SnapSafeTests +// + +import XCTest +@testable import SnapSafe + +final class VideoMetaDataFormattingTests: XCTestCase { + + private func make( + resolution: Size = Size(width: 1920, height: 1080), + duration: TimeInterval = 75, + dateTaken: Date = Date(timeIntervalSince1970: 1_700_000_000), + dateTakenSource: DateSource = .embedded, + location: GpsCoordinates? = GpsCoordinates(latitude: 37.7749, longitude: -122.4194), + orientation: TiffOrientation? = .up, + codec: String? = "HEVC", + frameRate: Double? = 30, + bitrate: Int? = 12_000_000, + fileSize: Int64 = 5_000_000 + ) -> VideoMetaData { + VideoMetaData( + resolution: resolution, duration: duration, dateTaken: dateTaken, + dateTakenSource: dateTakenSource, location: location, orientation: orientation, + codec: codec, frameRate: frameRate, bitrate: bitrate, fileSize: fileSize) + } + + func testResolutionString() { + XCTAssertEqual(make().resolutionString, "1920 × 1080") + XCTAssertEqual(make(resolution: Size(width: 0, height: 0)).resolutionString, "—") + } + + func testDurationString() { + XCTAssertEqual(make(duration: 75).durationString, "1:15") + XCTAssertEqual(make(duration: 3661).durationString, "1:01:01") + XCTAssertEqual(make(duration: 0).durationString, "—") + } + + func testFrameRateString() { + XCTAssertEqual(make(frameRate: 30).frameRateString, "30 fps") + XCTAssertEqual(make(frameRate: nil).frameRateString, "—") + XCTAssertEqual(make(frameRate: 0).frameRateString, "—") + } + + func testBitrateString() { + XCTAssertEqual(make(bitrate: 12_000_000).bitrateString, "12.0 Mbps") + XCTAssertEqual(make(bitrate: 500_000).bitrateString, "500 Kbps") + XCTAssertEqual(make(bitrate: nil).bitrateString, "—") + } + + func testCodecString() { + XCTAssertEqual(make(codec: "HEVC").codecString, "HEVC") + XCTAssertEqual(make(codec: nil).codecString, "—") + } + + func testLocationString() { + XCTAssertEqual( + make(location: GpsCoordinates(latitude: 37.7749, longitude: -122.4194)).locationString, + "37.774900°N, 122.419400°W") + XCTAssertEqual(make(location: nil).locationString, "—") + } + + func testOrientationString() { + XCTAssertEqual(make(orientation: .up).orientationString, "Normal") + XCTAssertEqual(make(orientation: .right).orientationString, "Rotated 90° CW") + XCTAssertEqual(make(orientation: nil).orientationString, "—") + } + + func testDateFromFilenameFlag() { + XCTAssertFalse(make(dateTakenSource: .embedded).isDateFromFilename) + XCTAssertTrue(make(dateTakenSource: .filename).isDateFromFilename) + } +} diff --git a/SnapSafeTests/VideoMetaDataTests.swift b/SnapSafeTests/VideoMetaDataTests.swift new file mode 100644 index 0000000..ab76f37 --- /dev/null +++ b/SnapSafeTests/VideoMetaDataTests.swift @@ -0,0 +1,166 @@ +// +// VideoMetaDataTests.swift +// SnapSafeTests +// +// Integration coverage for SecureImageRepository.getVideoMetaData: a synthetic +// .mov is encrypted to .secv and read back through EncryptedVideoDataSource. +// + +import XCTest +import AVFoundation +import CoreLocation +import CryptoKit +@testable import SnapSafe + +@MainActor +final class VideoMetaDataTests: XCTestCase { + + private var tempDirectory: URL! + private var videosDirectory: URL! + private var repository: SecureImageRepository! + + override func setUp() async throws { + try await super.setUp() + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + videosDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videosDir) + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + + // Real video encryption so EncryptedVideoDataSource can decrypt the SECV. + repository = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: FakeEncryptionScheme(), + videoEncryptionService: VideoEncryptionService(), + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: tempDirectory) + repository = nil + tempDirectory = nil + videosDirectory = nil + try await super.tearDown() + } + + // MARK: - Tests + + func testRoundTripWithEmbeddedMetadata() async throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let location = CLLocation(latitude: 37.7749, longitude: -122.4194) + let items = AVMetadataItemFactory.makeCaptureItems(location: location, date: date) + + let videoDef = try await makeEncryptedVideoDef(name: "video_20231114_221320", metadata: items) + let meta = try await repository.getVideoMetaData(videoDef) + + XCTAssertEqual(meta.dateTakenSource, .embedded, + "Date should come from the embedded creation date, not the filename") + XCTAssertEqual(meta.dateTaken.timeIntervalSince1970, date.timeIntervalSince1970, accuracy: 1.0, + "Embedded date \(meta.dateTaken) should match \(date)") + let coords = try XCTUnwrap(meta.location, "Embedded location should be returned") + XCTAssertEqual(coords.latitude, 37.7749, accuracy: 1e-4, "latitude was \(coords.latitude)") + XCTAssertEqual(coords.longitude, -122.4194, accuracy: 1e-4, "longitude was \(coords.longitude)") + XCTAssertEqual(meta.resolution.width, 160, "width was \(meta.resolution.width)") + XCTAssertEqual(meta.resolution.height, 90, "height was \(meta.resolution.height)") + XCTAssertGreaterThan(meta.duration, 0, "duration was \(meta.duration)") + XCTAssertNotNil(meta.codec, "codec should be populated from the track format") + XCTAssertGreaterThan(meta.fileSize, 0, "fileSize was \(meta.fileSize)") + } + + func testBackwardsCompatNoMetadataFallsBackToFilename() async throws { + let videoDef = try await makeEncryptedVideoDef(name: "video_20231114_221320", metadata: []) + let meta = try await repository.getVideoMetaData(videoDef) + + XCTAssertNil(meta.location, "No embedded location should yield nil") + XCTAssertEqual(meta.dateTakenSource, .filename, + "Without embedded date the source should be .filename") + let expected = try XCTUnwrap(videoDef.dateTaken()) + XCTAssertEqual(meta.dateTaken.timeIntervalSince1970, expected.timeIntervalSince1970, accuracy: 1.0, + "Date \(meta.dateTaken) should match filename-derived \(expected)") + XCTAssertEqual(meta.resolution.width, 160, "technical fields should still populate; width was \(meta.resolution.width)") + XCTAssertGreaterThan(meta.duration, 0, "duration was \(meta.duration)") + XCTAssertNotNil(meta.codec, "codec should still populate without capture metadata") + } + + func testImportedVideoPreservesPreExistingGps() async throws { + // A video that already carries GPS (different coordinates) must not be stripped. + let date = Date(timeIntervalSince1970: 1_600_000_000) + let location = CLLocation(latitude: -33.8688, longitude: 151.2093) + let items = AVMetadataItemFactory.makeCaptureItems(location: location, date: date) + + let videoDef = try await makeEncryptedVideoDef(name: "video_20200913_122640", metadata: items) + let meta = try await repository.getVideoMetaData(videoDef) + + let coords = try XCTUnwrap(meta.location, "Pre-existing GPS must be preserved") + XCTAssertEqual(coords.latitude, -33.8688, accuracy: 1e-4, "latitude was \(coords.latitude)") + XCTAssertEqual(coords.longitude, 151.2093, accuracy: 1e-4, "longitude was \(coords.longitude)") + } + + // MARK: - Helpers + + /// Write a tiny real .mov (with optional metadata), encrypt it to a .secv in + /// the repository's videos directory, and return its VideoDef. Encryption uses + /// the same all-zeros key FakeEncryptionScheme.getDerivedKey() returns, so the + /// repository can decrypt it. + private func makeEncryptedVideoDef(name: String, metadata: [AVMetadataItem]) async throws -> VideoDef { + let plainURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).mov") + try await writeSyntheticMovie(to: plainURL, metadata: metadata) + + let secvURL = videosDirectory.appendingPathComponent("\(name).secv") + // The real VideoEncryptionService opens the output with forWritingTo:, which + // requires the file to already exist. + FileManager.default.createFile(atPath: secvURL.path, contents: Data()) + + let key = SymmetricKey(data: Data(count: 32)) // matches FakeEncryptionScheme.getDerivedKey() + try await VideoEncryptionService().encryptVideoForDecoy( + inputURL: plainURL, outputURL: secvURL, encryptionKey: key) + + return VideoDef(videoName: name, videoFormat: "secv", videoFile: secvURL) + } + + /// Encode a 160x90, ~0.5s H.264 movie using AVAssetWriter, embedding `metadata`. + private func writeSyntheticMovie(to url: URL, metadata: [AVMetadataItem]) async throws { + let writer = try AVAssetWriter(outputURL: url, fileType: .mov) + if !metadata.isEmpty { writer.metadata = metadata } + + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 160, + AVVideoHeightKey: 90 + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB]) + + writer.add(input) + guard writer.startWriting() else { throw writer.error ?? NSError(domain: "test", code: 1) } + writer.startSession(atSourceTime: .zero) + + for frame in 0..<15 { + while !input.isReadyForMoreMediaData { try await Task.sleep(nanoseconds: 5_000_000) } + if let buffer = makePixelBuffer() { + adaptor.append(buffer, withPresentationTime: CMTime(value: Int64(frame), timescale: 30)) + } + } + input.markAsFinished() + await writer.finishWriting() + guard writer.status == .completed else { + throw writer.error ?? NSError(domain: "test", code: 2, + userInfo: [NSLocalizedDescriptionKey: "writer status \(writer.status.rawValue)"]) + } + } + + private func makePixelBuffer() -> CVPixelBuffer? { + var pixelBuffer: CVPixelBuffer? + let ok = CVPixelBufferCreate(kCFAllocatorDefault, 160, 90, kCVPixelFormatType_32ARGB, nil, &pixelBuffer) + guard ok == kCVReturnSuccess, let buffer = pixelBuffer else { return nil } + CVPixelBufferLockBaseAddress(buffer, []) + defer { CVPixelBufferUnlockBaseAddress(buffer, []) } + if let base = CVPixelBufferGetBaseAddress(buffer) { + memset(base, 0x7F, CVPixelBufferGetBytesPerRow(buffer) * 90) + } + return buffer + } +} diff --git a/SnapSafeTests/VideoThumbnailTests.swift b/SnapSafeTests/VideoThumbnailTests.swift new file mode 100644 index 0000000..e5a80aa --- /dev/null +++ b/SnapSafeTests/VideoThumbnailTests.swift @@ -0,0 +1,148 @@ +// +// VideoThumbnailTests.swift +// SnapSafeTests +// +// Covers storage/retrieval/deletion of encrypted video thumbnails, and that +// they are wiped on poison-pill activation (they are derived from real video +// frames, so they must be destroyed with the videos). +// + +import XCTest +import UIKit +@testable import SnapSafe + +@MainActor +final class VideoThumbnailTests: XCTestCase { + + private var repository: SecureImageRepository! + private var fakeEncryption: FakeEncryptionScheme! + private var tempDirectory: URL! + private var videosDirectory: URL! + private var videoThumbnailsDirectory: URL! + private var decoyVideoThumbnailsDirectory: URL! + + override func setUp() async throws { + try await super.setUp() + + tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + videosDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videosDir) + videoThumbnailsDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.videoThumbnailsDir) + decoyVideoThumbnailsDirectory = tempDirectory.appendingPathComponent(SecureImageRepository.decoyVideoThumbnailsDir) + + fakeEncryption = FakeEncryptionScheme() + repository = SecureImageRepository( + thumbnailCache: ThumbnailCache(), + encryptionScheme: fakeEncryption, + videoEncryptionService: FakeVideoEncryptionService(), + applicationSupportDirectory: tempDirectory, + cachesDirectory: tempDirectory + ) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: tempDirectory) + repository = nil + fakeEncryption = nil + tempDirectory = nil + videosDirectory = nil + videoThumbnailsDirectory = nil + decoyVideoThumbnailsDirectory = nil + try await super.tearDown() + } + + func testStoreVideoThumbnailWritesEncryptedFile() async { + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_20230101_120000") + + let file = videoThumbnailsDirectory.appendingPathComponent("video_20230101_120000.jpg") + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path), + "Storing a thumbnail should write an encrypted file in the video thumbnails directory") + } + + func testReadVideoThumbnailReturnsStoredImage() async { + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_20230101_120000") + + let videoDef = VideoDef( + videoName: "video_20230101_120000", + videoFormat: "secv", + videoFile: videosDirectory.appendingPathComponent("video_20230101_120000.secv") + ) + + let loaded = await repository.readVideoThumbnail(videoDef) + XCTAssertNotNil(loaded, "A stored thumbnail should be readable") + } + + func testDeleteVideoThumbnailRemovesFile() async { + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_20230101_120000") + let file = videoThumbnailsDirectory.appendingPathComponent("video_20230101_120000.jpg") + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path)) + + await repository.deleteVideoThumbnail(forVideoNamed: "video_20230101_120000") + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + } + + /// Security: video thumbnails are derived from real frames and must be + /// destroyed when the poison pill fires. + func testActivatePoisonPillDeletesAllVideoThumbnails() async { + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_a") + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_b") + XCTAssertTrue(FileManager.default.fileExists(atPath: videoThumbnailsDirectory.path)) + + await repository.activatePoisonPill() + + XCTAssertFalse(FileManager.default.fileExists(atPath: videoThumbnailsDirectory.path), + "All video thumbnails should be destroyed on poison pill activation") + } + + /// A decoy video's thumbnail must survive the poison pill (re-encrypted with + /// the poison key), while a non-decoy video's thumbnail is destroyed. + func testDecoyVideoThumbnailSurvivesPoisonPillWhileOthersAreDestroyed() async throws { + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + + // A decoy video + its thumbnail. + let decoyVideoFile = videosDirectory.appendingPathComponent("video_decoy.secv") + try Data("decoy-original".utf8).write(to: decoyVideoFile) + let decoyVideoDef = VideoDef(videoName: "video_decoy", videoFormat: "secv", videoFile: decoyVideoFile) + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_decoy") + + // A non-decoy video's thumbnail. + await repository.storeVideoThumbnail(makeTestJPEG(), forVideoNamed: "video_regular") + + // The decoy thumbnail re-encryption decrypts the current thumbnail; make + // the fake return some jpeg bytes for that decrypt. + fakeEncryption.decryptResult = Data("jpeg".utf8) + + // Mark the video as a decoy. + let added = await repository.addDecoyVideoWithKey(decoyVideoDef, keyData: Data(repeating: 0xAB, count: 32)) + XCTAssertTrue(added) + XCTAssertTrue(FileManager.default.fileExists( + atPath: decoyVideoThumbnailsDirectory.appendingPathComponent("video_decoy.jpg").path), + "Marking a video as a decoy should store a poison-key thumbnail copy") + + // When + await repository.activatePoisonPill() + + // Then — the decoy video's thumbnail is restored and available. + XCTAssertTrue(FileManager.default.fileExists( + atPath: videoThumbnailsDirectory.appendingPathComponent("video_decoy.jpg").path), + "Decoy video thumbnail must survive the poison pill so the gallery can show it") + + // And the non-decoy video's thumbnail is gone. + XCTAssertFalse(FileManager.default.fileExists( + atPath: videoThumbnailsDirectory.appendingPathComponent("video_regular.jpg").path), + "Non-decoy video thumbnail should be destroyed by the poison pill") + } + + // MARK: - Helpers + + private func makeTestJPEG() -> Data { + let size = CGSize(width: 40, height: 40) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + UIColor.systemBlue.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + return image.jpegData(compressionQuality: 0.7)! + } +} diff --git a/SnapSafeUITests/README.md b/SnapSafeUITests/README.md new file mode 100644 index 0000000..e14bec3 --- /dev/null +++ b/SnapSafeUITests/README.md @@ -0,0 +1,191 @@ +# SnapSafe UI Tests & Screenshots + +This directory contains UI tests for SnapSafe, including automated screenshot generation for the App Store. + +## Overview + +- **SnapSafeUITests.swift** - Basic UI tests that verify the app launches correctly +- **SnapSafeScreenshotTests.swift** - Comprehensive tests that navigate through the app and generate screenshots +- **SnapshotHelper.swift** - Fastlane snapshot integration (auto-generated) + +## Running Screenshot Tests + +### Option 1: Via Fastlane (Recommended for App Store) + +```bash +cd /path/to/SnapSafe +bundle exec fastlane snapshot +``` + +This will: +- Run the UI tests on all devices configured in `Snapfile` +- Generate screenshots for all languages configured in `Snapfile` +- Save screenshots to `./screenshots/` directory +- Create organized folders by device and language + +### Option 2: Via Xcode + +1. Open `SnapSafe.xcworkspace` +2. Select the SnapSafe scheme +3. Press `Cmd+U` to run all tests +4. Or press `Cmd+6` to open Test Navigator and run specific tests + +## How Screenshots Work + +The screenshot system uses `fastlane snapshot` which: + +1. **Launches your app** in a UI test +2. **Runs your UI tests** (from `SnapSafeScreenshotTests.swift`) +3. **Takes screenshots** when you call `snapshot("screenshot-name")` +4. **Organizes screenshots** by device size and language + +### Taking Screenshots in Tests + +```swift +@MainActor +func testGenerateScreenshots() throws { + app.launch() + + // Navigate to a screen + app.buttons["Settings"].tap() + + // Take a screenshot at this point + snapshot("01-Settings-Screen") + + // Continue navigating... +} +``` + +## Screenshot Naming Convention + +Screenshots are named with prefixes to ensure proper ordering: + +- `01-Onboarding-Intro` - First screen users see +- `02-PIN-Setup` - PIN creation screen +- `03-PIN-Verification` - PIN entry screen +- `04-Camera-Main` - Main camera view +- `05-Camera-Ready` - Camera with controls visible +- `06-Gallery-View` - Photo gallery +- `07-Photo-Detail` - Single photo view +- `08-Settings-Main` - Settings screen +- `09-Settings-Security` - Security settings +- `10-About` - About screen + +## Customizing Screenshots + +### Edit Test Flow + +Modify `SnapSafeScreenshotTests.swift` to change: +- Which screens are captured +- The order of navigation +- What actions are performed + +### Add New Screenshots + +```swift +// Navigate to your new screen +app.buttons["YourButton"].tap() +sleep(1) + +// Take the screenshot +snapshot("11-Your-New-Screen") +``` + +### Configure Devices & Languages + +Edit `fastlane/Snapfile`: + +```ruby +devices([ + "iPhone 17", + "iPhone 17 Pro Max", + "iPad Pro 11-inch (M4)" +]) + +languages([ + "en-US", + "es-ES", + "fr-FR" +]) +``` + +## UI Testing Launch Arguments + +The app detects these launch arguments for testing: + +- `-UITesting` - Enables UI testing mode +- `-SkipAuthentication` - Bypasses PIN entry for faster testing +- `-ResetOnboarding` - Resets onboarding state for testing intro screens + +Configure these in your test's `setUp`: + +```swift +app.launchArguments += ["-UITesting"] +app.launchArguments += ["-SkipAuthentication"] +``` + +## Handling Authentication in Tests + +Since SnapSafe requires a PIN, you have two options: + +### Option 1: Enter PIN in Test +```swift +private func enterTestPIN() { + let pinField = app.secureTextFields.firstMatch + pinField.tap() + app.typeText("1234") + app.buttons["Continue"].tap() +} +``` + +### Option 2: Bypass Authentication +Add logic in your app to skip authentication when `-SkipAuthentication` is set: + +```swift +// In your ContentViewModel or AuthorizationRepository +if UITestingHelper.shouldSkipAuthentication { + // Skip PIN verification + authorizeSession() +} +``` + +## Troubleshooting + +### Screenshots are blank or missing +- Make sure the UI elements are visible when `snapshot()` is called +- Add `sleep()` calls to wait for animations/transitions +- Check that element selectors match your actual UI + +### Tests fail to navigate +- Use the Xcode Accessibility Inspector to find element identifiers +- Add `.accessibilityIdentifier()` to SwiftUI views for reliable selection +- Check if buttons/elements are actually visible and hittable + +### Camera permission dialogs +- System permission dialogs can't be automated +- Pre-authorize camera access on simulators before running tests +- Or take screenshots that show the permission dialog as a feature + +## Best Practices + +1. **Use sleep() judiciously** - Wait for transitions, but not too long +2. **Test on clean state** - Reset simulator between test runs for consistency +3. **Use accessibility identifiers** - More reliable than text matching +4. **Test in multiple languages** - Ensure screenshots work for all locales +5. **Keep tests fast** - Minimize unnecessary navigation and delays + +## App Store Requirements + +For App Store screenshots, you need at least: +- **3-10 screenshots** per app size class +- **iPhone 6.7"** (iPhone 17 Pro Max) +- **iPhone 6.5"** (iPhone 14 Plus or 15 Plus) +- **iPad Pro 12.9"** (optional but recommended) + +The screenshots must be: +- PNG or JPEG format +- RGB color space +- No transparency +- Correct dimensions for each device size + +Fastlane snapshot handles all of this automatically! diff --git a/SnapSafeUITests/SnapSafeScreenshotTests.swift b/SnapSafeUITests/SnapSafeScreenshotTests.swift new file mode 100644 index 0000000..3125557 --- /dev/null +++ b/SnapSafeUITests/SnapSafeScreenshotTests.swift @@ -0,0 +1,80 @@ +// +// SnapSafeScreenshotTests.swift +// SnapSafeUITests +// +// Created by Claude on 10/13/25. +// + +import XCTest + +final class SnapSafeScreenshotTests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + + app = XCUIApplication() + + // Launch arguments to configure the app for UI testing + app.launchArguments += ["-UITesting"] + + // Set language and locale for consistent screenshots + app.launchArguments += ["-AppleLanguages", "(en)"] + app.launchArguments += ["-AppleLocale", "en_US"] + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Screenshot Tests + + @MainActor + func testGenerateScreenshots() throws { + setupSnapshot(app) + app.launch() + + // Wait for app to appear + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10), "App should launch") + + // Just take a simple screenshot of whatever screen appears + snapshot("01-Launch-Screen") + + // This is a simplified version - we'll expand it once it works + XCTAssertTrue(app.descendants(matching: .any).count > 0, "App should display content") + } + + // MARK: - Individual Screen Tests + // These can be run separately to test specific screens + + // Disabled - requires implementing -ResetOnboarding launch argument + // @MainActor + // func testWelcomeScreenOnly() throws { + // // Useful for testing just the onboarding/welcome screen + // setupSnapshot(app) + // app.launchArguments += ["-ResetOnboarding"] // Custom launch arg to reset state + // app.launch() + // sleep(2) + // + // snapshot("Welcome-Screen") + // + // XCTAssertTrue(app.descendants(matching: .any).count > 0, "App should display content") + // } + + @MainActor + func testCameraScreenOnly() throws { + // Useful for testing just the camera screen + setupSnapshot(app) + app.launch() + + // Wait for app to appear + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10), "App should launch") + + snapshot("Camera-Screen") + + // Verify app has content + XCTAssertTrue(app.descendants(matching: .any).count > 0, "App should display content") + } + +} diff --git a/SnapSafeUITests/SnapSafeUITestsLaunchTests.swift b/SnapSafeUITests/SnapSafeUITestsLaunchTests.swift index 1317ee1..e793ab1 100644 --- a/SnapSafeUITests/SnapSafeUITestsLaunchTests.swift +++ b/SnapSafeUITests/SnapSafeUITestsLaunchTests.swift @@ -13,6 +13,22 @@ final class SnapSafeUITestsLaunchTests: XCTestCase { true } + nonisolated(unsafe) private static var savedAppearance: XCUIDevice.Appearance = .light + + override class func setUp() { + super.setUp() + MainActor.assumeIsolated { + savedAppearance = XCUIDevice.shared.appearance + } + } + + override class func tearDown() { + MainActor.assumeIsolated { + XCUIDevice.shared.appearance = savedAppearance + } + super.tearDown() + } + override func setUpWithError() throws { continueAfterFailure = false } diff --git a/SnapSafeUITests/SnapshotHelper.swift b/SnapSafeUITests/SnapshotHelper.swift index 6dec130..8162776 100644 --- a/SnapSafeUITests/SnapshotHelper.swift +++ b/SnapSafeUITests/SnapshotHelper.swift @@ -20,6 +20,7 @@ func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) } +// periphery:ignore @MainActor func snapshot(_ name: String, waitForLoadingIndicator: Bool) { if waitForLoadingIndicator { diff --git a/docs/HOW-TO-RELEASE.md b/docs/HOW-TO-RELEASE.md index c125159..88b351f 100644 --- a/docs/HOW-TO-RELEASE.md +++ b/docs/HOW-TO-RELEASE.md @@ -1,6 +1,62 @@ # How to Release -- In XCode update the app version to a new SemVar value -- Commit and push all changes -- Tag the latest commit using the new SemVar value in the form of "v1.2.3" -- Push the tag, CI will now build and public that commit +Pushing a `v*` tag triggers `.github/workflows/publish-release.yml`, which: + +1. Builds the IPA (`fastlane build_release`) and publishes a GitHub Release with it attached. + Publishing that release triggers `notify-release.yml`, which posts to Discord immediately. +2. Rebuilds and uploads to App Store Connect (`fastlane deploy`). + It does **not** upload metadata or screenshots and does **not** submit for review. + +## 1. Before merging + +- [ ] Pick the new SemVer version (e.g. `1.4.0`). +- [ ] Run the full test suite on a **physical device**. The Secure Enclave and file-protection tests + (`HardwareEncryptionScheme*Tests`, `FileBasedSettingsDataSourceProtectionTests`) are skipped on the simulator. +- [ ] Run an App Store review readiness pass (the `appstore-review` Claude skill) on new features. + +## 2. Bump the version (on the feature branch) + +- [ ] In Xcode, set the SnapSafe target's Version (`MARKETING_VERSION`) to the new value. +- [ ] Leave Build (`CURRENT_PROJECT_VERSION`) at `1` unless re-uploading the same version (see Gotchas). +- [ ] Commit and push. + +## 3. Merge to main + +- [ ] Open a PR into `main` and wait for the "iOS Build and Test" check to pass (unit tests only). +- [ ] Merge, then update local `main`: + +```bash +git checkout main && git pull +``` + +## 4. Tag (this starts the release) + +Tag the merge commit on `main`, not the feature branch: + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +- [ ] Watch the "Publish iOS Release" workflow; both jobs must succeed. + +## 5. App Store Connect (manual) + +- [ ] Wait for the build to finish processing. +- [ ] Answer the export compliance question for the build. SnapSafe declares non-exempt encryption + (`ITSAppUsesNonExemptEncryption` in `Snap-Safe-Info.plist`) and has no `ITSEncryptionExportComplianceCode`, + so App Store Connect asks for every build. +- [ ] Create the new version, enter "What's New" release notes, and select the build. +- [ ] Update screenshots if the UI changed. +- [ ] Submit for review; release once approved. + +## Gotchas + +- **Discord fires before the App Store upload.** The notification goes out when the GitHub Release is + published at the end of job 1, even if job 2 later fails. +- **Retries need a new build number.** If job 2 fails after uploading, App Store Connect rejects a re-upload + with the same build number. Bump `CURRENT_PROJECT_VERSION` before retrying. +- **Re-pushing a tag duplicates the release.** Deleting and re-pushing a tag creates another GitHub Release + and another Discord post. +- **CI pins its Xcode version** (`xcode-select` step in the workflows). The shipped binary is built with that + toolchain, not your local Xcode. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2124c6f..5277612 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,8 +18,15 @@ opt_out_usage default_platform(:ios) platform :ios do + desc "Fail if any test source file is not a member of its test target" + lane :verify_test_membership do + script = File.expand_path("../scripts/check_test_target_membership.rb", __dir__) + sh("bundle", "exec", "ruby", script) + end + desc "Build the app" lane :build do + verify_test_membership run_tests( scheme: "SnapSafe", device: "iPhone 17", @@ -29,6 +36,7 @@ platform :ios do desc "Run unit tests" lane :test do + verify_test_membership run_tests( scheme: "SnapSafe", devices: ["iPhone 17"], @@ -122,4 +130,25 @@ platform :ios do precheck_include_in_app_purchases: false ) end + + desc "Generate App Store screenshots using Fastlane Snapshot" + lane :screenshots do + capture_ios_screenshots( + workspace: "SnapSafe.xcworkspace", + scheme: "SnapSafe" + ) + end + + desc "Run Periphery static analysis to detect unused Swift code" + lane :periphery do + sh( + "periphery", "scan", + "--project", "SnapSafe.xcworkspace", + "--schemes", "SnapSafe", + "--clean-build", + "--", + "-skipMacroValidation", + chdir: File.expand_path("..", __dir__) + ) + end end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..7200416 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,96 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios verify_test_membership + +```sh +[bundle exec] fastlane ios verify_test_membership +``` + +Fail if any test source file is not a member of its test target + +### ios build + +```sh +[bundle exec] fastlane ios build +``` + +Build the app + +### ios test + +```sh +[bundle exec] fastlane ios test +``` + +Run unit tests + +### ios run_multi_version_tests + +```sh +[bundle exec] fastlane ios run_multi_version_tests +``` + +Run tests on multiple iOS versions and device types + +### ios build_release + +```sh +[bundle exec] fastlane ios build_release +``` + +Build release IPA + +### ios beta + +```sh +[bundle exec] fastlane ios beta +``` + +Upload to TestFlight + +### ios deploy + +```sh +[bundle exec] fastlane ios deploy +``` + +Build and upload to App Store Connect + +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Generate App Store screenshots using Fastlane Snapshot + +### ios periphery + +```sh +[bundle exec] fastlane ios periphery +``` + +Run Periphery static analysis to detect unused Swift code + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/Snapfile b/fastlane/Snapfile index 69a65f3..4b4273c 100644 --- a/fastlane/Snapfile +++ b/fastlane/Snapfile @@ -4,10 +4,9 @@ devices([ "iPad Pro 11-inch (M4)" ]) - languages([ - "en-US", - "es-ES" - ]) +languages([ + "en-US" +]) # The name of the scheme which contains the UI Tests scheme("SnapSafe") diff --git a/scripts/check_test_target_membership.rb b/scripts/check_test_target_membership.rb new file mode 100755 index 0000000..8b4384d --- /dev/null +++ b/scripts/check_test_target_membership.rb @@ -0,0 +1,65 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Fails if any .swift file under a test directory is NOT compiled by its test +# target. Guards against the silent "file on disk but not a target member" bug, +# where a test file never compiles and its tests never run (the test bundle +# reports success while executing nothing). +# +# Run locally: bundle exec ruby scripts/check_test_target_membership.rb +# Runs in CI via the fastlane `test` lane. + +require "xcodeproj" +require "set" +require "pathname" + +REPO_ROOT = File.expand_path(File.join(__dir__, "..")) +PROJECT_PATH = File.join(REPO_ROOT, "SnapSafe.xcodeproj") + +# directory (relative to repo root) => target that must compile every .swift in it +MAPPING = { + "SnapSafeTests" => "SnapSafeTests" +}.freeze + +project = Xcodeproj::Project.open(PROJECT_PATH) +failures = [] + +MAPPING.each do |dir, target_name| + target = project.targets.find { |t| t.name == target_name } + abort "ERROR: target '#{target_name}' not found in #{PROJECT_PATH}" if target.nil? + + # Files the target actually compiles: explicit Sources build phase, plus any + # Xcode 16 file-system-synchronized groups (which auto-include their folders). + compiled = Set.new + target.source_build_phase.files.each do |build_file| + ref = build_file.file_ref + compiled << File.expand_path(ref.real_path.to_s) if ref + end + if target.respond_to?(:file_system_synchronized_groups) + Array(target.file_system_synchronized_groups).each do |group| + base = group.real_path.to_s + Dir.glob(File.join(base, "**", "*.swift")).each { |f| compiled << File.expand_path(f) } + end + end + + # Every .swift file on disk under the directory. + disk = Dir.glob(File.join(REPO_ROOT, dir, "**", "*.swift")).map { |f| File.expand_path(f) } + + disk.reject { |f| compiled.include?(f) }.sort.each do |f| + rel = Pathname.new(f).relative_path_from(Pathname.new(REPO_ROOT)).to_s + failures << "#{rel} (not a member of target '#{target_name}')" + end +end + +if failures.empty? + puts "✓ Test target membership: every test source file is compiled by its target." + exit 0 +end + +warn "✗ Test target membership check FAILED." +warn " These .swift files exist on disk but are not compiled, so their tests never run:" +failures.each { |m| warn " - #{m}" } +warn "" +warn " Add each file to its test target (Xcode: File Inspector > Target Membership," +warn " or via the xcodeproj gem), then re-run." +exit 1