├── .github └── workflows │ ├── README.md │ ├── build.yml │ └── publish-release.yml ├── .gitignore ├── .junie └── guidelines.md ├── LICENSE ├── PRIVACY.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── com │ │ └── darkrockstudios │ │ └── app │ │ └── securecamera │ │ └── SmokeTestUiTest.kt │ ├── debug │ └── res │ │ └── values │ │ ├── ic_launcher_background.xml │ │ └── strings.xml │ ├── full │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── darkrockstudios │ │ └── app │ │ └── securecamera │ │ ├── FullSnapSafeApplication.kt │ │ └── obfuscation │ │ └── MlFacialDetection.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── com │ │ │ └── darkrockstudios │ │ │ └── app │ │ │ └── securecamera │ │ │ ├── App.kt │ │ │ ├── AppModule.kt │ │ │ ├── BaseViewModel.kt │ │ │ ├── CameraVectorImages.kt │ │ │ ├── ConfirmDeletePhotoDialog.kt │ │ │ ├── ImageUtils.kt │ │ │ ├── KeepScreenOnEffect.kt │ │ │ ├── LocationRepository.kt │ │ │ ├── MainActivity.kt │ │ │ ├── ReentrantMutex.kt │ │ │ ├── ReleaseLogTree.kt │ │ │ ├── RequestLocationPermission.kt │ │ │ ├── SnapSafeApplication.kt │ │ │ ├── TimeUtils.kt │ │ │ ├── Util.kt │ │ │ ├── about │ │ │ └── AboutContent.kt │ │ │ ├── auth │ │ │ ├── AuthorizationRepository.kt │ │ │ ├── PinVerificationContent.kt │ │ │ ├── PinVerificationViewModel.kt │ │ │ └── SessionService.kt │ │ │ ├── camera │ │ │ ├── BottomCameraControls.kt │ │ │ ├── CameraContent.kt │ │ │ ├── CameraControls.kt │ │ │ ├── CameraPreview.kt │ │ │ ├── CameraState.kt │ │ │ ├── CapturedImage.kt │ │ │ ├── ImageUtils.kt │ │ │ ├── LevelIndicator.kt │ │ │ ├── NoCameraPermission.kt │ │ │ ├── PermissionRationaleDialog.kt │ │ │ ├── PhotoDef.kt │ │ │ ├── PhotoMetaData.kt │ │ │ ├── SecureImageRepository.kt │ │ │ ├── ThumbnailCache.kt │ │ │ ├── TopCameraControlsBar.kt │ │ │ ├── ZoomMeter.kt │ │ │ └── tiffOrientation.kt │ │ │ ├── gallery │ │ │ ├── GalleryContent.kt │ │ │ ├── GalleryTopNav.kt │ │ │ ├── GalleryViewModel.kt │ │ │ ├── rememberPhotoPickerLauncher.kt │ │ │ └── vibrateDevice.kt │ │ │ ├── import │ │ │ ├── ImportCancelReceiver.kt │ │ │ ├── ImportPhotosContent.kt │ │ │ ├── ImportPhotosTopBar.kt │ │ │ ├── ImportPhotosViewModel.kt │ │ │ └── ImportWorker.kt │ │ │ ├── introduction │ │ │ ├── IntroductionContent.kt │ │ │ ├── IntroductionSlide.kt │ │ │ ├── IntroductionViewModel.kt │ │ │ ├── PinCreationContent.kt │ │ │ └── SecurityContent.kt │ │ │ ├── navigation │ │ │ ├── AppDestinations.kt │ │ │ ├── AppNavigation.kt │ │ │ └── NavAnimations.kt │ │ │ ├── obfuscation │ │ │ ├── FacialDetection.kt │ │ │ ├── ObfuscatePhotoContent.kt │ │ │ ├── ObfuscatePhotoTopBar.kt │ │ │ ├── ObfuscatePhotoViewModel.kt │ │ │ ├── ObfuscationTools.kt │ │ │ └── Region.kt │ │ │ ├── preferences │ │ │ ├── AppPreferencesDataSource.kt │ │ │ └── XorCipher.kt │ │ │ ├── security │ │ │ ├── KeyParams.kt │ │ │ ├── SchemeConfig.kt │ │ │ ├── SecurityLevel.kt │ │ │ ├── SharedKey.kt │ │ │ ├── deviceInfo.kt │ │ │ ├── pin │ │ │ │ ├── PinRepository.kt │ │ │ │ ├── PinRepositoryHardware.kt │ │ │ │ └── PinRepositorySoftware.kt │ │ │ └── schemes │ │ │ │ ├── EncryptionScheme.kt │ │ │ │ ├── HardwareBackedEncryptionScheme.kt │ │ │ │ └── SoftwareEncryptionScheme.kt │ │ │ ├── settings │ │ │ ├── DecoyPhotoExplanationDialog.kt │ │ │ ├── LocationDialog.kt │ │ │ ├── PoisonPillDialog.kt │ │ │ ├── PoisonPillPinCreationDialog.kt │ │ │ ├── RemovePoisonPillDialog.kt │ │ │ ├── SecurityResetDialog.kt │ │ │ ├── SettingsContent.kt │ │ │ └── SettingsViewModel.kt │ │ │ ├── share │ │ │ ├── DecryptingImageProvider.kt │ │ │ └── ShareUtils.kt │ │ │ ├── ui │ │ │ ├── NotificationPermissionRationale.kt │ │ │ ├── PlayfulScaleVisibility.kt │ │ │ ├── UiEvent.kt │ │ │ ├── UiEventHandler.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── usecases │ │ │ ├── CreatePinUseCase.kt │ │ │ ├── InvalidateSessionUseCase.kt │ │ │ ├── MigratePinHash.kt │ │ │ ├── PinSizeUseCase.kt │ │ │ ├── PinStrengthCheckUseCase.kt │ │ │ ├── RemovePoisonPillIUseCase.kt │ │ │ ├── SecurityResetUseCase.kt │ │ │ └── VerifyPinUseCase.kt │ │ │ └── viewphoto │ │ │ ├── PhotoInfoDialog.kt │ │ │ ├── ViewPhotoContent.kt │ │ │ ├── ViewPhotoTopBar.kt │ │ │ └── ViewPhotoViewModel.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ └── ic_launcher_monochrome.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-it │ │ └── strings.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values-uk │ │ └── strings.xml │ │ ├── values-zh-rCN │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── file_paths.xml │ ├── oss │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── darkrockstudios │ │ └── app │ │ └── securecamera │ │ ├── OssSnapSafeApplication.kt │ │ └── obfuscation │ │ └── AndroidFacialDetection.kt │ └── test │ ├── kotlin │ └── com │ │ └── darkrockstudios │ │ └── app │ │ └── securecamera │ │ ├── TestClock.kt │ │ ├── auth │ │ └── AuthorizationManagerTest.kt │ │ ├── camera │ │ └── PhotoDefTest.kt │ │ ├── imagemanager │ │ └── SecureImageRepositoryTest.kt │ │ ├── navigation │ │ └── AppDestinationsTest.kt │ │ ├── preferences │ │ ├── AppPreferencesManagerTest.kt │ │ └── XorCipherTest.kt │ │ ├── security │ │ └── ShardedKeyTest.kt │ │ └── usecases │ │ ├── PinSizeUseCaseTest.kt │ │ ├── PinStrengthCheckUseCaseTest.kt │ │ └── VerifyPinUseCaseTest.kt │ └── resources │ └── red.jpg ├── build.gradle.kts ├── docs ├── SnapSafe Attack Vectors.md ├── SnapSafe Related Incidents.md └── SnapSafe Security on Android.md ├── fastlane ├── Appfile ├── Fastfile ├── README.md └── metadata │ └── android │ ├── de-DE │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── en-US │ ├── changelogs │ │ ├── 10.txt │ │ ├── 12.txt │ │ ├── 13.txt │ │ ├── 14.txt │ │ ├── 15.txt │ │ ├── 16.txt │ │ ├── 17.txt │ │ ├── 3.txt │ │ ├── 4.txt │ │ └── 8.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── es-ES │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── fr-FR │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── it-IT │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── pt-BR │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ ├── uk │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ ├── phoneScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ ├── 3_en-US.png │ │ │ ├── 4_en-US.png │ │ │ └── 5_en-US.png │ │ ├── sevenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ ├── 2_en-US.png │ │ │ └── 3_en-US.png │ │ └── tenInchScreenshots │ │ │ ├── 1_en-US.png │ │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt │ └── zh-CN │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ ├── icon.png │ ├── phoneScreenshots │ │ ├── 1_en-US.png │ │ ├── 2_en-US.png │ │ ├── 3_en-US.png │ │ ├── 4_en-US.png │ │ └── 5_en-US.png │ ├── sevenInchScreenshots │ │ ├── 1_en-US.png │ │ ├── 2_en-US.png │ │ └── 3_en-US.png │ └── tenInchScreenshots │ │ ├── 1_en-US.png │ │ └── 2_en-US.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json └── settings.gradle.kts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | pull_request: 7 | branches: [ "**" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | api-level: [ 34 ] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up JDK 20 | uses: actions/setup-java@v4 21 | with: 22 | distribution: 'temurin' 23 | java-version: '17' 24 | 25 | - name: Setup Gradle 26 | uses: gradle/actions/setup-gradle@v4 27 | 28 | - name: Grant execute permission for gradlew 29 | run: chmod +x gradlew 30 | 31 | - name: Decode Keystore 32 | env: 33 | ENCODED_KEYSTORE: ${{ secrets.ENCODED_KEYSTORE }} 34 | run: | 35 | echo $ENCODED_KEYSTORE | base64 -d > app/keystore.jks 36 | 37 | - name: Run Unit Tests 38 | run: ./gradlew test 39 | 40 | # - name: Enable KVM 41 | # run: | 42 | # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 43 | # sudo udevadm control --reload-rules 44 | # sudo udevadm trigger --name-match=kvm 45 | # 46 | # - name: AVD cache 47 | # uses: actions/cache@v4 48 | # id: avd-cache 49 | # with: 50 | # path: | 51 | # ~/.android/avd/* 52 | # ~/.android/adb* 53 | # key: avd-${{ matrix.api-level }} 54 | # 55 | # - name: create AVD and generate snapshot for caching 56 | # if: steps.avd-cache.outputs.cache-hit != 'true' 57 | # uses: reactivecircus/android-emulator-runner@v2 58 | # with: 59 | # api-level: ${{ matrix.api-level }} 60 | # arch: x86_64 61 | # target: default 62 | # force-avd-creation: false 63 | # disable-animations: false 64 | # script: echo "Generated AVD snapshot for caching." 65 | # 66 | # - name: Run Instrumentation Tests 67 | # uses: reactivecircus/android-emulator-runner@v2 68 | # with: 69 | # api-level: ${{ matrix.api-level }} 70 | # arch: x86_64 71 | # target: default 72 | # force-avd-creation: false 73 | # emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-front emulated -camera-back emulated 74 | # disable-animations: true 75 | # script: | 76 | # adb wait-for-device 77 | # adb shell input keyevent 82 78 | # ./gradlew connectedOssDebugAndroidTest --continue 79 | 80 | - name: Upload Test Reports 81 | if: failure() 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: android-test-report 85 | path: '**/build/reports/' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .idea 17 | app/release 18 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | # Project Guidelines 2 | 3 | We're creating an Android app that is a privacy focused camera. 4 | 5 | We want to take pictures, strip all metadata, store them in app-private encrypted storage 6 | and require a PIN separate from the device's PIN to view them. 7 | 8 | With all of that privacy in mind, we do want to be able to use the normal Android "share" 9 | system for getting the images out of our secure storage and into other apps. 10 | 11 | As a stretch goal, I want to provide further security features such as automatic face 12 | blurring. 13 | 14 | * The project consists of a single `app` module which is the whole application 15 | * We're using Kotlin 16 | * Prefer KMP libraries over Kotlin/JVM when possible 17 | * We're using Compose for the UI 18 | * We use tabs, no spaces 19 | * We should write unit tests when possible for new functions or features 20 | * We use Mockk for our test mocking 21 | * Comment code only sparingly. Obvious comments should be left out, only comment on code that is slight weird or complex -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025-2026 Adam Brown 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Secure Camera – Privacy Policy 2 | *Effective April 20 2025* 3 | 4 | Secure Camera was built with privacy as its first‑class feature. 5 | Below is the plain‑language summary of what happens to your data when you use the app: 6 | 7 | | What we collect | Where it goes | Why we need it | 8 | |-----------------|--------------|----------------| 9 | | **Nothing**—no personal data, usage analytics, or diagnostics | Nowhere (the app has *no* Internet permission) | We don't | 10 | 11 | --- 12 | 13 | ## 1. Data Collection 14 | Secure Camera does **not** collect, store, or transmit any personally identifiable information (PII), usage data, or any other kind of data what so ever. All photos and videos remain solely on your device, unless you explicitly share them. 15 | 16 | ## 2. Network Access 17 | The app’s manifest deliberately omits `android.permission.INTERNET`. Consequently, Secure Camera is technically incapable of connecting to the Internet, cloud services, or third‑party APIs. 18 | 19 | ## 3. File Access 20 | Secure Camera operates entirely within its private app storage for saving images you capture. It does **not** request broad file‑system access (e.g., `MANAGE_EXTERNAL_STORAGE`) and never reads files outside its own sandbox. 21 | 22 | ## 4. Permissions Used 23 | * `android.permission.CAMERA` – required to capture photos and video. 24 | * `android.permission.ACCESS_COARSE_LOCATION` User's can optionally grant this permission if they want obfuscated location data saved with their photos. 25 | * `android.permission.ACCESS_FINE_LOCATION` User's can optionally grant this permission if they want precise location data saved with their photos. 26 | 27 | No other runtime permissions are requested. 28 | 29 | ## 5. Third‑Party Services 30 | None. Secure Camera is 100 % open source and contains no advertising or analytics libraries. 31 | 32 | ## 6. Changes to This Policy 33 | If Secure Camera’s behavior ever changes in a way that affects privacy, this document will be updated in the source repository and a new release will note those changes. 34 | 35 | ## 7. Contact 36 | Questions or concerns? Open an issue in the [GitHub repository](https://github.com/Wavesonics/SecureCamera). 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snap Safe 2 | *The camera that minds its own business.* 3 | 4 | _Available on:_ 5 | 6 | [![Google Play](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.darkrockstudios.app.securecamera%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=com.darkrockstudios.app.securecamera) 7 | [![F-Droid](https://img.shields.io/f-droid/v/com.darkrockstudios.app.securecamera?logo=FDROID)](https://f-droid.org/en/packages/com.darkrockstudios.app.securecamera/) 8 | [![GitHub](https://img.shields.io/github/v/release/SecureCamera/SecureCameraAndroid?include_prereleases&logo=github)](https://github.com/SecureCamera/SecureCameraAndroid/releases/latest) 9 | 10 | [snapsafe.org](https://snapsafe.org/) 11 | 12 | [![featureGraphic.png](fastlane/metadata/android/en-US/images/featureGraphic.png)](http://www.snapsafe.org) 13 | 14 | [![codebeat badge](https://codebeat.co/badges/1d47f0fa-2155-4e63-85ba-aafd01812d8c)](https://codebeat.co/projects/github-com-securecamera-securecameraandroid-main) 15 | 16 | ---- 17 | 18 | ## Why Snap Safe? 19 | 20 | **SnapSafe** is a camera app that has been engineered from the ground up to protect your photos. 21 | 22 | Attacks come in many forms, from accidental swipes, to intrusive surveillance, and even malicious code. 23 | **SnapSafe** can protect your photos from all angles. 24 | 25 | ### Key Features 26 | 27 | * 🔒 **Zero‑Leak Design** – The app has no internet access; android backups are prevented.. 28 | * 🛡️ **Fully Encrypted** – Shots are written to encrypted, app‑private storage. 29 | * 🔢 **PIN‑Locked Gallery** – A separate PIN stands between curious thumbs and your photos. 30 | * 📤 **Secure Sharing** – Metadata is scrubbed and filenames are randomized when you share. 31 | * 😶‍🌫️ **Auto-Face Blur** – Obfuscate faces automatically with our secure blur algorithm. 32 | * 🗺️ **Granular Location** – Add coarse, fine, or zero location data—your call. 33 | * ☠️ **Poison Pill** – Set a special PIN, that when entered, appears to work normally but actually deletes your existing 34 | photos. 35 | * 🎭 **Decoy Photos** – Select innocuous decoy photos, these will be preserved when your Poison Pill is activated. 36 | * 👀 **100 % Open Source** – Auditable code in plain sight. 37 | 38 | ### On the Roadmap 39 | 40 | * Encrypted video recording. _Maybe._ 41 | * Improved photo-taking experience 42 | 43 | ## Read our papers on SnapSafe 44 | 45 | - [Security Design](docs/SnapSafe%20Security%20on%20Android.md) 46 | - [Attack Vectors](docs/SnapSafe%20Attack%20Vectors.md) 47 | - [Related Incidents](docs/SnapSafe%20Related%20Incidents.md) 48 | 49 | --- 50 | 51 | ## Contributing 52 | 53 | Pull requests are happily accepted. 54 | 55 | Start with an issue or draft PR and we can talk it through. 56 | 57 | ### Automated Publishing 58 | 59 | The project uses GitHub Actions to automatically build and publish new releases to Google Play when a tag with the 60 | format `v*` (e.g., `v1.0.0`) is pushed. See the [GitHub Actions workflow documentation](.github/workflows/README.md) for 61 | details on how this works and the required setup. 62 | 63 | The project includes a pre-configured [FastLane](https://fastlane.tools/) setup for automating the deployment process. 64 | See the [FastLane documentation](fastlane/README.md) for details on how to use it for manual deployments or to customize 65 | the metadata. 66 | 67 | --- 68 | 69 | ## License 70 | 71 | SnapSafe is released under the [MIT License](LICENSE). Use it, fork it, improve it—just keep it open. 72 | 73 | --- 74 | 75 | ## Privacy 76 | 77 | Our full, ultra‑brief Privacy Policy lives in [PRIVACY.md](PRIVACY.md). Spoiler: we collect nothing. 78 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/debug/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #9C27B0 4 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | SnapSafeDev 3 | -------------------------------------------------------------------------------- /app/src/full/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/full/kotlin/com/darkrockstudios/app/securecamera/FullSnapSafeApplication.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import com.darkrockstudios.app.securecamera.obfuscation.FacialDetection 4 | import com.darkrockstudios.app.securecamera.obfuscation.MlFacialDetection 5 | import org.koin.core.module.Module 6 | import org.koin.core.module.dsl.factoryOf 7 | import org.koin.dsl.bind 8 | import org.koin.dsl.module 9 | 10 | class FullSnapSafeApplication : SnapSafeApplication() { 11 | override fun flavorModule(): Module = module { 12 | factoryOf(::MlFacialDetection) bind FacialDetection::class 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/full/kotlin/com/darkrockstudios/app/securecamera/obfuscation/MlFacialDetection.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.obfuscation 2 | 3 | import android.graphics.Bitmap 4 | import com.google.mlkit.vision.common.InputImage 5 | import com.google.mlkit.vision.face.FaceDetection 6 | import com.google.mlkit.vision.face.FaceDetectorOptions 7 | import com.google.mlkit.vision.face.FaceLandmark 8 | import kotlinx.coroutines.suspendCancellableCoroutine 9 | import timber.log.Timber 10 | import kotlin.coroutines.resume 11 | 12 | class MlFacialDetection : FacialDetection { 13 | private val detector = FaceDetection.getClient( 14 | FaceDetectorOptions.Builder() 15 | .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) 16 | .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) 17 | .setMinFaceSize(0.02f) 18 | .build() 19 | ) 20 | 21 | override suspend fun processForFaces(bitmap: Bitmap): List { 22 | val inputImage = InputImage.fromBitmap(bitmap, 0) 23 | 24 | return suspendCancellableCoroutine { continuation -> 25 | detector.process(inputImage) 26 | .addOnSuccessListener { foundFaces -> 27 | val newRegions = foundFaces.map { face -> 28 | val leftEye = 29 | face.allLandmarks.find { it.landmarkType == FaceLandmark.LEFT_EYE } 30 | val rightEye = 31 | face.allLandmarks.find { it.landmarkType == FaceLandmark.RIGHT_EYE } 32 | val eyes = if (leftEye != null && rightEye != null) { 33 | FacialDetection.FoundFace.Eyes( 34 | left = leftEye.position, 35 | right = rightEye.position, 36 | ) 37 | } else { 38 | null 39 | } 40 | FacialDetection.FoundFace( 41 | boundingBox = face.boundingBox, 42 | eyes = eyes 43 | ) 44 | } 45 | continuation.resume(newRegions) 46 | }.addOnFailureListener { e -> 47 | Timber.Forest.e(e, "Failed face detection in Image") 48 | continuation.resume(emptyList()) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 28 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 58 | 61 | 62 | 67 | 68 | 71 | 72 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/App.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import androidx.compose.foundation.layout.imePadding 4 | import androidx.compose.material3.Scaffold 5 | import androidx.compose.material3.SnackbarHost 6 | import androidx.compose.material3.SnackbarHostState 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.lifecycle.compose.LifecycleResumeEffect 10 | import androidx.navigation.NavHostController 11 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 12 | import com.darkrockstudios.app.securecamera.navigation.AppNavHost 13 | import com.darkrockstudios.app.securecamera.navigation.enforceAuth 14 | import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource 15 | import com.darkrockstudios.app.securecamera.ui.theme.SecureCameraTheme 16 | import org.koin.compose.KoinContext 17 | import org.koin.compose.koinInject 18 | 19 | @Composable 20 | fun App( 21 | capturePhoto: MutableState, 22 | startDestination: String, 23 | navController: NavHostController 24 | ) { 25 | KoinContext { 26 | SecureCameraTheme { 27 | val snackbarHostState = remember { SnackbarHostState() } 28 | val preferencesManager = koinInject() 29 | val authorizationRepository = koinInject() 30 | 31 | val hasCompletedIntro by preferencesManager.hasCompletedIntro.collectAsState(initial = null) 32 | 33 | VerifySessionOnResume(navController, hasCompletedIntro, authorizationRepository) 34 | 35 | if (hasCompletedIntro != null) { 36 | Scaffold( 37 | snackbarHost = { SnackbarHost(snackbarHostState) }, 38 | modifier = Modifier.imePadding() 39 | ) { paddingValues -> 40 | AppNavHost( 41 | navController = navController, 42 | capturePhoto = capturePhoto, 43 | modifier = Modifier, 44 | snackbarHostState = snackbarHostState, 45 | startDestination = startDestination, 46 | paddingValues = paddingValues, 47 | ) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | @Composable 55 | private fun VerifySessionOnResume( 56 | navController: NavHostController, 57 | hasCompletedIntro: Boolean?, 58 | authorizationRepository: AuthorizationRepository 59 | ) { 60 | var requireAuthCheck = remember { false } 61 | LifecycleResumeEffect(hasCompletedIntro) { 62 | if (hasCompletedIntro == true && requireAuthCheck) { 63 | enforceAuth(authorizationRepository, navController.currentDestination, navController) 64 | } 65 | onPauseOrDispose { 66 | requireAuthCheck = true 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import androidx.work.WorkManager 4 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 5 | import com.darkrockstudios.app.securecamera.auth.PinVerificationViewModel 6 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 7 | import com.darkrockstudios.app.securecamera.camera.ThumbnailCache 8 | import com.darkrockstudios.app.securecamera.gallery.GalleryViewModel 9 | import com.darkrockstudios.app.securecamera.import.ImportPhotosViewModel 10 | import com.darkrockstudios.app.securecamera.introduction.IntroductionViewModel 11 | import com.darkrockstudios.app.securecamera.obfuscation.ObfuscatePhotoViewModel 12 | import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource 13 | import com.darkrockstudios.app.securecamera.security.DeviceInfo 14 | import com.darkrockstudios.app.securecamera.security.SecurityLevel 15 | import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector 16 | import com.darkrockstudios.app.securecamera.security.pin.PinRepository 17 | import com.darkrockstudios.app.securecamera.security.pin.PinRepositoryHardware 18 | import com.darkrockstudios.app.securecamera.security.pin.PinRepositorySoftware 19 | import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme 20 | import com.darkrockstudios.app.securecamera.security.schemes.HardwareBackedEncryptionScheme 21 | import com.darkrockstudios.app.securecamera.security.schemes.SoftwareEncryptionScheme 22 | import com.darkrockstudios.app.securecamera.settings.SettingsViewModel 23 | import com.darkrockstudios.app.securecamera.usecases.* 24 | import com.darkrockstudios.app.securecamera.viewphoto.ViewPhotoViewModel 25 | import org.koin.core.module.dsl.factoryOf 26 | import org.koin.core.module.dsl.singleOf 27 | import org.koin.core.module.dsl.viewModelOf 28 | import org.koin.dsl.bind 29 | import org.koin.dsl.module 30 | import kotlin.time.Clock 31 | 32 | val appModule = module { 33 | 34 | single { Clock.System } bind Clock::class 35 | singleOf(::SecureImageRepository) 36 | single { AppPreferencesDataSource(context = get()) } 37 | single { 38 | AuthorizationRepository( 39 | preferences = get(), 40 | pinRepository = get(), 41 | encryptionScheme = get(), 42 | context = get(), 43 | clock = get() 44 | ) 45 | } 46 | singleOf(::LocationRepository) 47 | single { 48 | val detector = get() 49 | when (detector.detectSecurityLevel()) { 50 | SecurityLevel.SOFTWARE -> 51 | SoftwareEncryptionScheme(get()) 52 | SecurityLevel.TEE, SecurityLevel.STRONGBOX -> { 53 | HardwareBackedEncryptionScheme(get(), get(), get()) 54 | } 55 | } 56 | } bind EncryptionScheme::class 57 | single { 58 | val detector = get() 59 | when (detector.detectSecurityLevel()) { 60 | SecurityLevel.SOFTWARE -> 61 | PinRepositorySoftware(get(), get()) 62 | 63 | SecurityLevel.TEE, SecurityLevel.STRONGBOX -> { 64 | PinRepositoryHardware(get(), get(), get()) 65 | } 66 | } 67 | } bind PinRepository::class 68 | singleOf(::SecurityLevelDetector) 69 | 70 | single { WorkManager.getInstance(get()) } 71 | 72 | factoryOf(::DeviceInfo) 73 | 74 | factoryOf(::ThumbnailCache) 75 | factoryOf(::SecurityResetUseCase) 76 | factoryOf(::PinStrengthCheckUseCase) 77 | factoryOf(::VerifyPinUseCase) 78 | factoryOf(::CreatePinUseCase) 79 | factoryOf(::PinSizeUseCase) 80 | factoryOf(::RemovePoisonPillIUseCase) 81 | factoryOf(::MigratePinHash) 82 | factoryOf(::InvalidateSessionUseCase) 83 | 84 | viewModelOf(::ObfuscatePhotoViewModel) 85 | viewModelOf(::ViewPhotoViewModel) 86 | viewModelOf(::GalleryViewModel) 87 | viewModelOf(::SettingsViewModel) 88 | viewModelOf(::IntroductionViewModel) 89 | viewModelOf(::PinVerificationViewModel) 90 | viewModelOf(::ImportPhotosViewModel) 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.darkrockstudios.app.securecamera.ui.UiEvent 6 | import kotlinx.coroutines.flow.* 7 | import kotlinx.coroutines.launch 8 | 9 | abstract class BaseViewModel : ViewModel() { 10 | protected val _uiState by lazy { MutableStateFlow(createState()) } 11 | val uiState: StateFlow by lazy { _uiState.asStateFlow() } 12 | 13 | protected abstract fun createState(): S 14 | 15 | private val _events = MutableSharedFlow() 16 | val events = _events.asSharedFlow() 17 | 18 | fun showMessage(message: String) { 19 | viewModelScope.launch { 20 | _events.emit(UiEvent.ShowSnack(message)) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ConfirmDeletePhotoDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | 9 | @Composable 10 | fun ConfirmDeletePhotoDialog( 11 | selectedCount: Int, 12 | onConfirm: () -> Unit, 13 | onDismiss: () -> Unit, 14 | ) { 15 | AlertDialog( 16 | onDismissRequest = onDismiss, 17 | title = { 18 | Text( 19 | text = stringResource( 20 | id = if (selectedCount > 1) R.string.delete_photo_title_plural else R.string.delete_photo_title_singular 21 | ) 22 | ) 23 | }, 24 | text = { 25 | Text( 26 | text = if (selectedCount > 1) { 27 | stringResource(id = R.string.delete_photo_message_plural, selectedCount) 28 | } else { 29 | stringResource(id = R.string.delete_photo_message_singular) 30 | } 31 | ) 32 | }, 33 | confirmButton = { 34 | TextButton( 35 | onClick = { 36 | onConfirm() 37 | } 38 | ) { 39 | Text(stringResource(id = R.string.delete_button)) 40 | } 41 | }, 42 | dismissButton = { 43 | TextButton( 44 | onClick = onDismiss 45 | ) { 46 | Text(stringResource(id = R.string.cancel_button)) 47 | } 48 | } 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ImageUtils.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import android.graphics.BitmapFactory 4 | import androidx.compose.ui.graphics.ImageBitmap 5 | import androidx.compose.ui.graphics.asImageBitmap 6 | 7 | /** 8 | * Extension function to convert a ByteArray to an ImageBitmap. 9 | * This is useful for displaying images captured from the camera in Jetpack Compose UI. 10 | * 11 | * @return ImageBitmap created from the ByteArray 12 | */ 13 | fun ByteArray.decodeToImageBitmap(): ImageBitmap { 14 | // Convert ByteArray to Android Bitmap first 15 | val bitmap = BitmapFactory.decodeByteArray(this, 0, this.size) 16 | 17 | // Then convert Android Bitmap to Compose's ImageBitmap 18 | return bitmap.asImageBitmap() 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/KeepScreenOnEffect.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.ui.platform.LocalView 6 | 7 | @Composable 8 | fun KeepScreenOnEffect() { 9 | val view = LocalView.current 10 | 11 | DisposableEffect(view) { 12 | view.keepScreenOn = true 13 | onDispose { view.keepScreenOn = false } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ReentrantMutex.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import kotlinx.coroutines.Job 4 | import kotlinx.coroutines.currentCoroutineContext 5 | import kotlinx.coroutines.sync.Mutex 6 | 7 | class ReentrantMutex { 8 | private val mutex = Mutex() 9 | private var owner: Any? = null 10 | private var recursionCount = 0 11 | 12 | suspend fun lock() { 13 | val current = currentCoroutineContext()[Job] 14 | if (owner == current) { 15 | recursionCount++ 16 | return 17 | } 18 | mutex.lock() 19 | owner = current 20 | recursionCount = 1 21 | } 22 | 23 | suspend fun unlock() { 24 | if (owner != currentCoroutineContext()[Job]) { 25 | throw IllegalStateException("Not the owner of the lock") 26 | } 27 | recursionCount-- 28 | if (recursionCount == 0) { 29 | owner = null 30 | mutex.unlock() 31 | } 32 | } 33 | 34 | suspend fun withLock(action: suspend () -> T): T { 35 | lock() 36 | try { 37 | return action() 38 | } finally { 39 | unlock() 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ReleaseLogTree.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import android.util.Log 4 | import timber.log.Timber 5 | import kotlin.math.min 6 | 7 | 8 | class ReleaseLogTree : Timber.Tree() { 9 | override fun isLoggable(tag: String?, priority: Int): Boolean { 10 | // Don't log VERBOSE or DEBUG 11 | if (priority == Log.VERBOSE || priority == Log.DEBUG) { 12 | return false 13 | } 14 | 15 | // Log only ERROR, WARN and WTF, INFO 16 | return true 17 | } 18 | 19 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 20 | if (isLoggable(tag, priority)) { 21 | // Message is short enough, doesn't need to be broken into chunks 22 | 23 | if (message.length < MAX_LOG_LENGTH) { 24 | if (priority == Log.ASSERT) { 25 | Log.wtf(tag, message) 26 | } else { 27 | Log.println(priority, tag, message) 28 | } 29 | return 30 | } 31 | 32 | // Split by line, then ensure each line can fit into Log's max length 33 | var i = 0 34 | val length = message.length 35 | while (i < length) { 36 | var newline = message.indexOf('\n', i) 37 | newline = if (newline != -1) newline else length 38 | do { 39 | val end = min(newline, i + MAX_LOG_LENGTH) 40 | val part = message.substring(i, end) 41 | if (priority == Log.ASSERT) { 42 | Log.wtf(tag, part) 43 | } else { 44 | Log.println(priority, tag, part) 45 | } 46 | i = end 47 | } while (i < newline) 48 | i++ 49 | } 50 | } 51 | } 52 | 53 | companion object { 54 | private const val MAX_LOG_LENGTH = 4000 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/RequestLocationPermission.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import android.Manifest 4 | import android.content.pm.PackageManager 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.core.content.ContextCompat 11 | 12 | @Composable 13 | fun RequestLocationPermission(onGranted: () -> Unit) { 14 | val ctx = LocalContext.current 15 | 16 | val fineLauncher = rememberLauncherForActivityResult( 17 | contract = ActivityResultContracts.RequestPermission() 18 | ) { granted -> 19 | if (granted) { 20 | onGranted() 21 | } else { 22 | // If FINE location was denied, check if COARSE location is granted 23 | val coarsePerm = Manifest.permission.ACCESS_COARSE_LOCATION 24 | if (ContextCompat.checkSelfPermission(ctx, coarsePerm) == PackageManager.PERMISSION_GRANTED) { 25 | onGranted() 26 | } 27 | // We don't request COARSE if FINE was denied - as per requirements 28 | } 29 | } 30 | 31 | LaunchedEffect(Unit) { 32 | val finePerm = Manifest.permission.ACCESS_FINE_LOCATION 33 | val coarsePerm = Manifest.permission.ACCESS_COARSE_LOCATION 34 | 35 | // First check if FINE location is granted 36 | if (ContextCompat.checkSelfPermission(ctx, finePerm) == PackageManager.PERMISSION_GRANTED) { 37 | onGranted() 38 | } 39 | // Then check if COARSE location is granted 40 | else if (ContextCompat.checkSelfPermission(ctx, coarsePerm) == PackageManager.PERMISSION_GRANTED) { 41 | onGranted() 42 | } 43 | // If neither is granted, request FINE location 44 | else { 45 | fineLauncher.launch(finePerm) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/SnapSafeApplication.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import android.app.Application 4 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 5 | import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource 6 | import com.darkrockstudios.app.securecamera.usecases.SecurityResetUseCase 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.runBlocking 9 | import org.koin.android.ext.koin.androidContext 10 | import org.koin.android.ext.koin.androidLogger 11 | import org.koin.core.component.KoinComponent 12 | import org.koin.core.component.inject 13 | import org.koin.core.context.startKoin 14 | import org.koin.core.module.Module 15 | import org.koin.dsl.module 16 | import timber.log.Timber 17 | 18 | open class SnapSafeApplication : Application(), KoinComponent { 19 | private val imageManager by inject() 20 | private val preferences by inject() 21 | private val securityReset by inject() 22 | 23 | override fun onCreate() { 24 | super.onCreate() 25 | 26 | if (BuildConfig.DEBUG) { 27 | Timber.plant(Timber.DebugTree()) 28 | } else { 29 | Timber.plant(ReleaseLogTree()) 30 | } 31 | 32 | startKoin { 33 | androidLogger() 34 | androidContext(this@SnapSafeApplication) 35 | modules(appModule, flavorModule()) 36 | } 37 | 38 | handleMigrationFromBeta() 39 | } 40 | 41 | open fun flavorModule(): Module = module { } 42 | 43 | override fun onTrimMemory(level: Int) { 44 | super.onTrimMemory(level) 45 | imageManager.thumbnailCache.clear() 46 | } 47 | 48 | override fun onLowMemory() { 49 | super.onLowMemory() 50 | imageManager.thumbnailCache.clear() 51 | } 52 | 53 | // DELETE ME after a few versions 54 | private fun handleMigrationFromBeta() { 55 | runBlocking { 56 | val intoComplete = (preferences.hasCompletedIntro.first() == true) 57 | val isProdReady = (preferences.isProdReady.first() == true) 58 | val wasInBeta = intoComplete && !isProdReady 59 | if (wasInBeta) { 60 | securityReset.reset() 61 | preferences.markProdReady() 62 | } else if (!isProdReady) { 63 | preferences.markProdReady() 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/TimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import timber.log.Timber 4 | 5 | /** 6 | * Measures the execution time of the provided block of code in milliseconds. 7 | * @param block The code block to measure. 8 | * @return A Pair containing the result of the block execution and the time taken in milliseconds. 9 | */ 10 | inline fun measureTime(block: () -> T): Pair { 11 | val startTime = System.currentTimeMillis() 12 | val result = block() 13 | val endTime = System.currentTimeMillis() 14 | val executionTime = endTime - startTime 15 | return Pair(result, executionTime) 16 | } 17 | 18 | /** 19 | * Measures the execution time of the provided block of code, prints the time with a message, 20 | * and returns the result of the block execution. 21 | * @param message The message to print along with the measured time. 22 | * @param block The code block to measure. 23 | * @return The result of the block execution. 24 | */ 25 | inline fun measureAndReport(message: String, block: () -> T): T { 26 | val (result, time) = measureTime(block) 27 | Timber.d("$message: $time ms") 28 | return result 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/Util.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import kotlinx.coroutines.sync.Mutex 5 | import kotlinx.coroutines.sync.withLock 6 | 7 | inline fun Mutex.withLockBlocking(owner: Any? = null, crossinline action: () -> T): T { 8 | return runBlocking { 9 | withLock(owner, action) 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/BottomCameraControls.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Camera 10 | import androidx.compose.material.icons.filled.PhotoLibrary 11 | import androidx.compose.material.icons.filled.Settings 12 | import androidx.compose.material3.* 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.semantics.contentDescription 20 | import androidx.compose.ui.semantics.semantics 21 | import androidx.compose.ui.unit.dp 22 | import androidx.navigation.NavHostController 23 | import com.darkrockstudios.app.securecamera.R 24 | import com.darkrockstudios.app.securecamera.navigation.AppDestinations 25 | 26 | @Composable 27 | fun BottomCameraControls( 28 | modifier: Modifier = Modifier, 29 | onCapture: (() -> Unit)?, 30 | isLoading: Boolean, 31 | navController: NavHostController, 32 | ) { 33 | val context = LocalContext.current 34 | 35 | Box( 36 | modifier = modifier 37 | .fillMaxWidth() 38 | .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), 39 | ) { 40 | ElevatedButton( 41 | onClick = { navController.navigate(AppDestinations.SETTINGS_ROUTE) }, 42 | enabled = isLoading.not(), 43 | modifier = Modifier.align(Alignment.BottomStart), 44 | ) { 45 | Icon( 46 | imageVector = Icons.Filled.Settings, 47 | contentDescription = stringResource(R.string.camera_settings_button), 48 | modifier = Modifier.size(32.dp), 49 | ) 50 | } 51 | 52 | if (onCapture != null) { 53 | FilledTonalButton( 54 | onClick = onCapture, 55 | modifier = Modifier 56 | .size(80.dp) 57 | .clip(CircleShape) 58 | .align(Alignment.BottomCenter) 59 | .semantics { 60 | contentDescription = context.getString(R.string.camera_shutter_button_desc) 61 | }, 62 | colors = ButtonDefaults.filledTonalButtonColors( 63 | containerColor = MaterialTheme.colorScheme.primary, 64 | ), 65 | ) { 66 | Icon( 67 | imageVector = Icons.Filled.Camera, 68 | contentDescription = stringResource(id = R.string.camera_capture_content_description), 69 | tint = MaterialTheme.colorScheme.onPrimary, 70 | modifier = Modifier.size(32.dp), 71 | ) 72 | } 73 | } 74 | 75 | ElevatedButton( 76 | onClick = { navController.navigate(AppDestinations.GALLERY_ROUTE) }, 77 | enabled = isLoading.not(), 78 | modifier = Modifier.align(Alignment.BottomEnd), 79 | ) { 80 | Icon( 81 | imageVector = Icons.Filled.PhotoLibrary, 82 | contentDescription = stringResource(id = R.string.camera_gallery_content_description), 83 | modifier = Modifier.size(32.dp), 84 | ) 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraContent.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.Manifest 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.navigation.NavHostController 10 | import com.darkrockstudios.app.securecamera.KeepScreenOnEffect 11 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 12 | import com.google.accompanist.permissions.rememberMultiplePermissionsState 13 | 14 | @OptIn(ExperimentalPermissionsApi::class) 15 | @Composable 16 | internal fun CameraContent( 17 | capturePhoto: MutableState, 18 | navController: NavHostController, 19 | modifier: Modifier, 20 | paddingValues: PaddingValues, 21 | ) { 22 | KeepScreenOnEffect() 23 | 24 | val permissionsState = rememberMultiplePermissionsState( 25 | permissions = listOf( 26 | Manifest.permission.CAMERA, 27 | ) 28 | ) 29 | 30 | var showRationaleDialog by remember { mutableStateOf(false) } 31 | 32 | LaunchedEffect(Unit) { 33 | if (!permissionsState.allPermissionsGranted && permissionsState.shouldShowRationale) { 34 | showRationaleDialog = true 35 | } else { 36 | permissionsState.launchMultiplePermissionRequest() 37 | } 38 | } 39 | 40 | if (showRationaleDialog) { 41 | CameraPermissionRationaleDialog( 42 | onContinue = { 43 | showRationaleDialog = false 44 | permissionsState.launchMultiplePermissionRequest() 45 | }, 46 | onDismiss = { showRationaleDialog = false } 47 | ) 48 | } 49 | 50 | Box( 51 | modifier = modifier 52 | .fillMaxSize() 53 | ) { 54 | if (permissionsState.allPermissionsGranted) { 55 | val cameraState = rememberCameraState() 56 | CameraPreview( 57 | modifier = Modifier.fillMaxSize(), 58 | state = cameraState 59 | ) 60 | 61 | CameraControls( 62 | cameraController = cameraState, 63 | capturePhoto = capturePhoto, 64 | navController = navController, 65 | paddingValues = paddingValues, 66 | ) 67 | } else { 68 | NoCameraPermission(navController, permissionsState) 69 | } 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraPreview.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import androidx.camera.core.CameraSelector 4 | import androidx.camera.lifecycle.ProcessCameraProvider 5 | import androidx.camera.view.PreviewView 6 | import androidx.compose.animation.core.animateFloatAsState 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.foundation.Canvas 9 | import androidx.compose.foundation.gestures.detectTapGestures 10 | import androidx.compose.foundation.gestures.rememberTransformableState 11 | import androidx.compose.foundation.gestures.transformable 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.runtime.* 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.input.pointer.pointerInput 18 | import androidx.compose.ui.platform.LocalContext 19 | import androidx.compose.ui.viewinterop.AndroidView 20 | import androidx.lifecycle.compose.LocalLifecycleOwner 21 | import kotlinx.coroutines.delay 22 | import kotlinx.coroutines.launch 23 | 24 | /** Create and remember a [CameraState] inside composition. */ 25 | @Composable 26 | fun rememberCameraState(initialLensFacing: Int = CameraSelector.LENS_FACING_BACK): CameraState { 27 | val context = LocalContext.current 28 | val lifecycleOwner = LocalLifecycleOwner.current 29 | 30 | val provider = remember { ProcessCameraProvider.getInstance(context).get() } 31 | val previewView = remember { 32 | PreviewView(context).apply { scaleType = PreviewView.ScaleType.FIT_CENTER } 33 | } 34 | 35 | val state = remember { 36 | CameraState( 37 | previewView = previewView, 38 | lifecycleOwner = lifecycleOwner, 39 | providerFuture = provider, 40 | initialLensFacing = initialLensFacing 41 | ) 42 | } 43 | 44 | // (Re)bind camera whenever lens changes. 45 | DisposableEffect(state.lensFacing) { 46 | state.bindCamera() 47 | onDispose { provider.unbindAll() } 48 | } 49 | 50 | DisposableEffect(Unit) { 51 | onDispose { state.shutdown() } 52 | } 53 | 54 | return state 55 | } 56 | 57 | /** Composable that renders the camera preview and UI using the provided [CameraState]. */ 58 | @Composable 59 | fun CameraPreview( 60 | state: CameraState, 61 | modifier: Modifier = Modifier.fillMaxSize() 62 | ) { 63 | val scope = rememberCoroutineScope() 64 | // Pinch‑to‑zoom transformable 65 | val zoomState = rememberTransformableState { zoomChange, _, _ -> 66 | state.camera?.let { cam -> 67 | val current = cam.cameraInfo.zoomState.value?.zoomRatio ?: 1f 68 | val newZoom = (current * zoomChange).coerceIn(state.minZoom, state.maxZoom) 69 | state.setZoomRatio(newZoom) 70 | } 71 | } 72 | 73 | // focus indicator fade animation 74 | val indicatorAlpha by animateFloatAsState( 75 | targetValue = if (state.focusOffset != null) 1f else 0f, 76 | animationSpec = tween(durationMillis = 300) 77 | ) 78 | 79 | Box( 80 | modifier = modifier 81 | .transformable(zoomState) 82 | .pointerInput(Unit) { 83 | detectTapGestures { offset -> 84 | state.focusAt(offset) 85 | 86 | scope.launch { 87 | delay(800) 88 | state.clearFocusOffset() 89 | } 90 | } 91 | } 92 | ) { 93 | // Camera preview 94 | AndroidView( 95 | factory = { state.previewView }, 96 | modifier = Modifier.fillMaxSize() 97 | ) 98 | 99 | // Draw focus ring 100 | state.focusOffset?.let { pos -> 101 | Canvas( 102 | modifier = Modifier 103 | .fillMaxSize() 104 | .pointerInput(Unit) {} // intercept taps 105 | ) { 106 | drawCircle( 107 | color = Color.White.copy(alpha = indicatorAlpha), 108 | radius = 40f, 109 | center = pos 110 | ) 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CapturedImage.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.graphics.Bitmap 4 | import kotlin.time.Instant 5 | 6 | data class CapturedImage( 7 | val sensorBitmap: Bitmap, 8 | val timestamp: Instant, 9 | val rotationDegrees: Int, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ImageUtils.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.BitmapFactory 5 | import android.graphics.Matrix 6 | import androidx.camera.core.ImageProxy 7 | import java.io.ByteArrayOutputStream 8 | import java.nio.ByteBuffer 9 | 10 | internal fun Bitmap.rotate(degrees: Int): Bitmap { 11 | val m = Matrix(); 12 | m.postRotate(degrees.toFloat()) 13 | return Bitmap.createBitmap(this, 0, 0, width, height, m, true) 14 | } 15 | 16 | internal fun Bitmap.toJpegByteArray(quality: Int = 90): ByteArray { 17 | val out = ByteArrayOutputStream() 18 | compress(Bitmap.CompressFormat.JPEG, quality, out) 19 | return out.toByteArray() 20 | } 21 | 22 | internal fun imageProxyToBytes(proxy: ImageProxy): ByteArray { 23 | val buffer: ByteBuffer = proxy.planes[0].buffer 24 | return ByteArray(buffer.remaining()).also { buffer.get(it) } 25 | } 26 | 27 | internal fun rotateAndEncode(proxy: ImageProxy, quality: Int = 90): ByteArray { 28 | val bmp = BitmapFactory.decodeByteArray(imageProxyToBytes(proxy), 0, proxy.planes[0].buffer.remaining()) 29 | val rotation = proxy.imageInfo.rotationDegrees 30 | val rotated = if (rotation == 0) bmp else bmp.rotate(rotation) 31 | return rotated.toJpegByteArray().also { if (rotated != bmp) bmp.recycle() } 32 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/NoCameraPermission.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.provider.Settings 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material3.Button 8 | import androidx.compose.material3.Card 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.navigation.NavHostController 17 | import com.darkrockstudios.app.securecamera.R 18 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 19 | import com.google.accompanist.permissions.MultiplePermissionsState 20 | 21 | @OptIn(ExperimentalPermissionsApi::class) 22 | @Composable 23 | fun NoCameraPermission( 24 | navController: NavHostController, 25 | permissionsState: MultiplePermissionsState, 26 | ) { 27 | val context = LocalContext.current 28 | 29 | Box( 30 | modifier = Modifier 31 | .fillMaxSize() 32 | ) { 33 | Card(modifier = Modifier.align(Alignment.Center)) { 34 | Column( 35 | modifier = Modifier.padding(16.dp), 36 | horizontalAlignment = Alignment.CenterHorizontally, 37 | verticalArrangement = Arrangement.Center, 38 | ) { 39 | Text( 40 | stringResource(R.string.camera_permissions_required), 41 | modifier = Modifier.padding(16.dp) 42 | ) 43 | 44 | if (permissionsState.revokedPermissions.isNotEmpty()) { 45 | Button(onClick = { 46 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { 47 | data = Uri.fromParts("package", context.packageName, null) 48 | } 49 | context.startActivity(intent) 50 | }) { 51 | Text(text = stringResource(R.string.camera_open_settings)) 52 | } 53 | } 54 | } 55 | } 56 | 57 | BottomCameraControls( 58 | modifier = Modifier.align(Alignment.BottomCenter), 59 | navController = navController, 60 | onCapture = null, 61 | isLoading = false, 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PermissionRationaleDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.CameraAlt 5 | import androidx.compose.material3.AlertDialog 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.Text 8 | import androidx.compose.material3.TextButton 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.graphics.vector.ImageVector 11 | 12 | @Composable 13 | fun CameraPermissionRationaleDialog( 14 | onContinue: () -> Unit, 15 | onDismiss: () -> Unit, 16 | title: String = "Camera access required", 17 | text: String = 18 | "We use the camera to capture photos. Images stay on your device unless you choose to share them.\n\n" + 19 | "We use location to save location of each photo when it's taken. This metadata is automatically stripped out by default when sharing the photos.\n\n" + 20 | "Denying Location permissions is okay, Location metadata will just not be attached to your photos.", 21 | 22 | icon: ImageVector = Icons.Default.CameraAlt 23 | ) { 24 | AlertDialog( 25 | icon = { Icon(icon, contentDescription = null) }, 26 | title = { Text(title) }, 27 | text = { Text(text) }, 28 | onDismissRequest = onDismiss, 29 | confirmButton = { 30 | TextButton(onClick = onContinue) { Text("Request Permissions") } 31 | }, 32 | dismissButton = { 33 | TextButton(onClick = onDismiss) { Text("Not now") } 34 | } 35 | ) 36 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoDef.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import timber.log.Timber 4 | import java.io.File 5 | import java.text.ParseException 6 | import java.text.SimpleDateFormat 7 | import java.util.* 8 | 9 | data class PhotoDef( 10 | val photoName: String, 11 | val photoFormat: String, 12 | val photoFile: File, 13 | ) { 14 | fun dateTaken(): Date { 15 | try { 16 | val dateString = photoName.removePrefix("photo_").removeSuffix(".jpg") 17 | val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss_SS", Locale.US) 18 | return dateFormat.parse(dateString) ?: Date() 19 | } catch (e: ParseException) { 20 | Timber.w(e, "Failed to parse photo name to date") 21 | return Date() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/PhotoMetaData.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.util.Size 4 | import com.ashampoo.kim.model.GpsCoordinates 5 | import com.ashampoo.kim.model.TiffOrientation 6 | import java.util.* 7 | 8 | data class PhotoMetaData( 9 | val name: String, 10 | val resolution: Size, 11 | val dateTaken: Date, 12 | val orientation: TiffOrientation?, 13 | val location: GpsCoordinates?, 14 | ) { 15 | fun resolutionString() = "${resolution.width}x${resolution.height}" 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/ThumbnailCache.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import android.graphics.Bitmap 4 | import android.util.LruCache 5 | import com.darkrockstudios.app.securecamera.withLockBlocking 6 | import kotlinx.coroutines.sync.Mutex 7 | import kotlinx.coroutines.sync.withLock 8 | 9 | class ThumbnailCache { 10 | private val maxMemory = Runtime.getRuntime().maxMemory() / 1024 11 | private val cacheSize = (maxMemory / 8).toInt() 12 | 13 | private val thumbnailCache = object : LruCache(cacheSize) { 14 | override fun sizeOf(key: String, bitmap: Bitmap): Int { 15 | // The cache size will be measured in kilobytes 16 | return bitmap.byteCount / 1024 17 | } 18 | } 19 | private val cacheMutex = Mutex() 20 | 21 | suspend fun getThumbnail(photo: PhotoDef): Bitmap? { 22 | return cacheMutex.withLock { 23 | thumbnailCache.get(photo.photoName) 24 | } 25 | } 26 | 27 | fun evictThumbnail(photo: PhotoDef) { 28 | cacheMutex.withLockBlocking { 29 | thumbnailCache.remove(photo.photoName) 30 | } 31 | } 32 | 33 | fun clear() { 34 | cacheMutex.withLockBlocking { 35 | thumbnailCache.evictAll() 36 | } 37 | } 38 | 39 | suspend fun putThumbnail(photo: PhotoDef, thumbnailBitmap: Bitmap) { 40 | cacheMutex.withLock { 41 | thumbnailCache.put(photo.photoName, thumbnailBitmap) 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/tiffOrientation.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.camera 2 | 3 | import com.ashampoo.kim.model.TiffOrientation 4 | 5 | /** 6 | * Pure logic: convert a rotation (0/90/180/270 deg) and an optional mirror flag 7 | * into the correct TIFF orientation value. 8 | */ 9 | fun calculateTiffOrientation( 10 | rotationDegrees: Int, 11 | isMirrored: Boolean = false 12 | ): TiffOrientation = when (rotationDegrees) { 13 | 0 -> if (isMirrored) TiffOrientation.MIRROR_HORIZONTAL 14 | else TiffOrientation.STANDARD 15 | 16 | 90 -> if (isMirrored) TiffOrientation.MIRROR_HORIZONTAL_AND_ROTATE_LEFT 17 | else TiffOrientation.ROTATE_RIGHT 18 | 19 | 180 -> if (isMirrored) TiffOrientation.MIRROR_VERTICAL 20 | else TiffOrientation.UPSIDE_DOWN 21 | 22 | 270 -> if (isMirrored) TiffOrientation.MIRROR_HORIZONTAL_AND_ROTATE_RIGHT 23 | else TiffOrientation.ROTATE_LEFT 24 | 25 | else -> TiffOrientation.STANDARD // Fallback 26 | } 27 | 28 | fun TiffOrientation.toDegrees(): Int = when (this) { 29 | TiffOrientation.STANDARD, 30 | TiffOrientation.MIRROR_HORIZONTAL -> 0 31 | 32 | TiffOrientation.ROTATE_RIGHT, 33 | TiffOrientation.MIRROR_HORIZONTAL_AND_ROTATE_LEFT -> 90 34 | 35 | TiffOrientation.UPSIDE_DOWN, 36 | TiffOrientation.MIRROR_VERTICAL -> 180 37 | 38 | TiffOrientation.ROTATE_LEFT, 39 | TiffOrientation.MIRROR_HORIZONTAL_AND_ROTATE_RIGHT -> 270 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/rememberPhotoPickerLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.gallery 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.net.Uri 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.platform.LocalContext 10 | import com.darkrockstudios.app.securecamera.R 11 | 12 | @Composable 13 | fun rememberPhotoPickerLauncher(onImagesSelected: (List) -> Unit): () -> Unit { 14 | val context = LocalContext.current 15 | val pickImagesLauncher = rememberLauncherForActivityResult( 16 | contract = ActivityResultContracts.StartActivityForResult() 17 | ) { result -> 18 | if (result.resultCode == Activity.RESULT_OK) { 19 | val selectedUris = mutableListOf() 20 | val data = result.data 21 | 22 | // Handle single image 23 | val singleData = data?.data 24 | if (singleData != null) { 25 | selectedUris.add(singleData) 26 | } 27 | // Handle multiple images 28 | else if (data?.clipData != null) { 29 | val clipData = data.clipData!! 30 | for (i in 0 until clipData.itemCount) { 31 | selectedUris.add(clipData.getItemAt(i).uri) 32 | } 33 | } 34 | 35 | if (selectedUris.isNotEmpty()) { 36 | onImagesSelected(selectedUris) 37 | } 38 | } 39 | } 40 | 41 | return { 42 | val intent = Intent(Intent.ACTION_GET_CONTENT).apply { 43 | type = "image/jpeg" 44 | putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) 45 | } 46 | pickImagesLauncher.launch( 47 | Intent.createChooser(intent, context.getString(R.string.import_chooser_intent_title)) 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/vibrateDevice.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.gallery 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.os.VibrationEffect 6 | import android.os.Vibrator 7 | import android.os.VibratorManager 8 | 9 | fun vibrateDevice(context: Context) { 10 | val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 11 | val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager 12 | vibratorManager.defaultVibrator 13 | } else { 14 | @Suppress("DEPRECATION") 15 | context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator 16 | } 17 | 18 | vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportCancelReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.import 2 | 3 | import android.app.NotificationManager 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import androidx.work.WorkManager 8 | import com.darkrockstudios.app.securecamera.import.ImportWorker.Companion.NOTIFICATION_ID 9 | import timber.log.Timber 10 | 11 | /** 12 | * BroadcastReceiver to handle cancellation of the ImportWorker 13 | */ 14 | class ImportCancelReceiver : BroadcastReceiver() { 15 | companion object { 16 | const val ACTION_CANCEL_IMPORT = "com.darkrockstudios.app.securecamera.import.CANCEL_IMPORT" 17 | const val EXTRA_WORKER_ID = "EXTRA_WORKER_ID" 18 | } 19 | 20 | override fun onReceive(context: Context, intent: Intent) { 21 | if (intent.action == ACTION_CANCEL_IMPORT) { 22 | val workerId = intent.getStringExtra(EXTRA_WORKER_ID) 23 | if (workerId != null) { 24 | Timber.d("Cancelling import worker: $workerId") 25 | WorkManager.getInstance(context).cancelWorkById(workerId.toUUID()) 26 | 27 | // Dismiss notification when worker is cancelled 28 | val notificationManager = 29 | context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 30 | notificationManager.cancel(NOTIFICATION_ID) 31 | } else { 32 | Timber.e("No worker ID provided in cancel intent") 33 | } 34 | } 35 | } 36 | 37 | private fun String.toUUID() = java.util.UUID.fromString(this) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/import/ImportPhotosTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.import 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Close 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.IconButton 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TopAppBar 12 | import androidx.compose.material3.TopAppBarDefaults 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.unit.dp 17 | import androidx.navigation.NavController 18 | import com.darkrockstudios.app.securecamera.R 19 | 20 | @OptIn(ExperimentalMaterial3Api::class) 21 | @Composable 22 | fun ImportPhotosTopBar( 23 | navController: NavController, 24 | ) { 25 | TopAppBar( 26 | title = { 27 | Text( 28 | stringResource(id = R.string.import_photos_title), 29 | color = MaterialTheme.colorScheme.onPrimaryContainer, 30 | ) 31 | }, 32 | colors = TopAppBarDefaults.topAppBarColors( 33 | containerColor = MaterialTheme.colorScheme.primaryContainer, 34 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer 35 | ), 36 | navigationIcon = { 37 | IconButton( 38 | onClick = { navController.navigateUp() }, 39 | modifier = Modifier.padding(8.dp) 40 | ) { 41 | Icon( 42 | imageVector = Icons.Filled.Close, 43 | contentDescription = stringResource(id = R.string.close_import_photos_content_description), 44 | tint = MaterialTheme.colorScheme.onPrimaryContainer, 45 | ) 46 | } 47 | } 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/introduction/IntroductionSlide.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.introduction 2 | 3 | import androidx.compose.ui.graphics.vector.ImageVector 4 | 5 | /** 6 | * Represents a slide in the introduction 7 | */ 8 | data class IntroductionSlide( 9 | val icon: ImageVector, 10 | val title: String, 11 | val description: String, 12 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/navigation/NavAnimations.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.navigation 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.animation.core.CubicBezierEasing 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.unit.IntOffset 8 | import androidx.navigation.NamedNavArgument 9 | import androidx.navigation.NavBackStackEntry 10 | import androidx.navigation.NavDeepLink 11 | import androidx.navigation.compose.composable 12 | 13 | private const val Duration = 240 14 | private val StandardEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) 15 | 16 | private val fSpec = tween( 17 | durationMillis = Duration, 18 | easing = StandardEasing 19 | ) 20 | 21 | private val iSpec = tween( 22 | durationMillis = Duration, 23 | easing = StandardEasing 24 | ) 25 | 26 | fun defaultEnter(): EnterTransition = 27 | fadeIn(animationSpec = fSpec) + 28 | scaleIn( 29 | // start a little smaller – matches Settings/Files app 30 | initialScale = 0.75f, 31 | animationSpec = fSpec 32 | ) + 33 | slideInVertically( 34 | // slide up ≈10 % of the height 35 | initialOffsetY = { it / 10 }, 36 | animationSpec = iSpec 37 | ) 38 | 39 | fun defaultExit(): ExitTransition = 40 | fadeOut(animationSpec = fSpec) + 41 | scaleOut( 42 | targetScale = 0.9f, // recedes slightly 43 | animationSpec = fSpec 44 | ) + 45 | slideOutVertically( 46 | targetOffsetY = { -it / 10 }, // moves up a bit 47 | animationSpec = iSpec 48 | ) 49 | 50 | fun defaultPopEnter(): EnterTransition = 51 | fadeIn(animationSpec = fSpec) + 52 | scaleIn( 53 | initialScale = 0.9f, // small lift-off, then settles 54 | animationSpec = fSpec 55 | ) + 56 | slideInVertically( 57 | initialOffsetY = { -it / 10 }, // comes down slightly 58 | animationSpec = iSpec 59 | ) 60 | 61 | fun defaultPopExit(): ExitTransition = 62 | fadeOut(animationSpec = fSpec) + 63 | scaleOut( 64 | targetScale = 0.75f, 65 | animationSpec = fSpec 66 | ) + 67 | slideOutVertically( 68 | targetOffsetY = { it / 10 }, // slides downward 69 | animationSpec = iSpec 70 | ) 71 | 72 | fun androidx.navigation.NavGraphBuilder.defaultAnimatedComposable( 73 | route: String, 74 | arguments: List = emptyList(), 75 | deepLinks: List = emptyList(), 76 | content: @Composable() (AnimatedContentScope.(NavBackStackEntry) -> Unit) 77 | ) { 78 | composable( 79 | route = route, 80 | arguments = arguments, 81 | deepLinks = deepLinks, 82 | enterTransition = { defaultEnter() }, 83 | exitTransition = { defaultExit() }, 84 | popEnterTransition = { defaultPopEnter() }, 85 | popExitTransition = { defaultPopExit() }, 86 | content = content, 87 | ) 88 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/FacialDetection.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.obfuscation 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.PointF 5 | import android.graphics.Rect 6 | 7 | interface FacialDetection { 8 | suspend fun processForFaces(bitmap: Bitmap): List 9 | 10 | data class FoundFace( 11 | val boundingBox: Rect, 12 | val eyes: Eyes? 13 | ) { 14 | data class Eyes( 15 | val left: PointF, 16 | val right: PointF, 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/ObfuscatePhotoTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.obfuscation 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.AddBox 7 | import androidx.compose.material.icons.filled.ArrowBack 8 | import androidx.compose.material3.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import com.darkrockstudios.app.securecamera.R 14 | 15 | @OptIn(ExperimentalMaterial3Api::class) 16 | @Composable 17 | fun ObfuscatePhotoTopBar( 18 | onAddRegionClick: () -> Unit, 19 | isFindingFaces: Boolean, 20 | isCreatingRegion: Boolean = false, 21 | onBackPressed: () -> Unit, 22 | ) { 23 | TopAppBar( 24 | title = { 25 | Text( 26 | stringResource(id = R.string.obfuscate_photo_button), 27 | color = MaterialTheme.colorScheme.onPrimaryContainer, 28 | ) 29 | }, 30 | colors = TopAppBarDefaults.topAppBarColors( 31 | containerColor = MaterialTheme.colorScheme.primaryContainer, 32 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer 33 | ), 34 | navigationIcon = { 35 | IconButton( 36 | onClick = { onBackPressed() }, 37 | modifier = Modifier.padding(8.dp) 38 | ) { 39 | Icon( 40 | imageVector = Icons.Filled.ArrowBack, 41 | contentDescription = stringResource(id = R.string.settings_back_description), 42 | tint = MaterialTheme.colorScheme.onPrimaryContainer, 43 | ) 44 | } 45 | }, 46 | actions = { 47 | if (!isCreatingRegion) { 48 | if (isFindingFaces) { 49 | CircularProgressIndicator( 50 | modifier = Modifier 51 | .padding(8.dp) 52 | .size(24.dp), 53 | color = MaterialTheme.colorScheme.onPrimaryContainer, 54 | strokeWidth = 2.dp 55 | ) 56 | } else { 57 | // Add Region button 58 | IconButton( 59 | onClick = onAddRegionClick, 60 | modifier = Modifier.padding(8.dp), 61 | ) { 62 | Icon( 63 | imageVector = Icons.Filled.AddBox, 64 | contentDescription = stringResource(id = R.string.obscure_action_button_add_region), 65 | ) 66 | } 67 | } 68 | } 69 | } 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/obfuscation/Region.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.obfuscation 2 | 3 | import android.graphics.Rect 4 | 5 | sealed class Region( 6 | val rect: Rect, 7 | val obfuscate: Boolean = true, 8 | ) 9 | 10 | class FaceRegion( 11 | val face: FacialDetection.FoundFace, 12 | obfuscate: Boolean = true, 13 | ) : Region(face.boundingBox, obfuscate) 14 | 15 | class ManualRegion( 16 | rect: Rect, 17 | obfuscate: Boolean = true, 18 | ) : Region(rect, obfuscate) 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/XorCipher.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.preferences 2 | 3 | import java.nio.charset.StandardCharsets 4 | import kotlin.io.encoding.Base64 5 | import kotlin.io.encoding.ExperimentalEncodingApi 6 | 7 | 8 | object XorCipher { 9 | @OptIn(ExperimentalEncodingApi::class) 10 | fun encrypt(plaintext: String, key: String): String { 11 | if (key.isBlank()) error("Key must not be empty!") 12 | val obfuscated = xor(plaintext.toByteArray(StandardCharsets.UTF_8), key) 13 | return String(Base64.encodeToByteArray(obfuscated)) 14 | } 15 | 16 | @OptIn(ExperimentalEncodingApi::class) 17 | fun decrypt(ciphertextB64: String, key: String): String { 18 | if (key.isBlank()) error("Key must not be empty!") 19 | val decoded: ByteArray = Base64.decode(ciphertextB64) 20 | return String(xor(decoded, key), StandardCharsets.UTF_8) 21 | } 22 | 23 | private fun xor(input: ByteArray, key: String): ByteArray { 24 | val key = key.toByteArray(StandardCharsets.UTF_8) 25 | val out = ByteArray(input.size) 26 | 27 | for (i in input.indices) { 28 | out[i] = (input[i].toInt() xor key[i % key.size].toInt()).toByte() 29 | } 30 | return out 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/KeyParams.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security 2 | 3 | import dev.whyoleg.cryptography.BinarySize 4 | import dev.whyoleg.cryptography.BinarySize.Companion.bytes 5 | 6 | data class KeyParams( 7 | val iterations: Int = 600_000, 8 | val outputSize: BinarySize = 32.bytes, 9 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/SchemeConfig.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.time.Duration 5 | 6 | @Serializable 7 | sealed class SchemeConfig 8 | 9 | @Serializable 10 | data object SoftwareSchemeConfig : SchemeConfig() 11 | 12 | @Serializable 13 | data class HardwareSchemeConfig( 14 | val requireBiometricAttestation: Boolean, 15 | val authTimeout: Duration, 16 | val ephemeralKey: Boolean 17 | ) : SchemeConfig() -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/SharedKey.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security 2 | 3 | import dev.whyoleg.cryptography.random.CryptographyRandom 4 | import java.nio.ByteBuffer 5 | import kotlin.random.Random 6 | 7 | class ShardedKey( 8 | key: ByteArray 9 | ) { 10 | private val part1: ByteBuffer 11 | private val part2: ByteBuffer 12 | private val keySize: Int = key.size 13 | 14 | init { 15 | // Randomly size the storage buffer so it is not the exact length of an AES key 16 | val part1Size = keySize + CryptographyRandom.nextInt(3, 155) 17 | part1 = ByteBuffer.allocateDirect(part1Size) 18 | val part1Array = ByteArray(part1Size) 19 | CryptographyRandom.nextBytes(part1Array) 20 | part1.put(part1Array) 21 | part1.flip() 22 | 23 | // Best effort to ensure the two key parts don't live next to each other in memory. 24 | // This will be released once the constructor returns (or when ever the GC runs next) 25 | val spacerSize = Random.nextInt(978, 2893) 26 | val spacer = ByteBuffer.allocateDirect(spacerSize) 27 | val spacerArray = ByteArray(spacerSize) 28 | CryptographyRandom.nextBytes(spacerArray) 29 | spacer.put(spacerArray) 30 | spacer.flip() 31 | 32 | // Use it so it doesn't get optimized out 33 | (0..Random.nextInt(13)).forEachIndexed { i, _ -> 34 | val x = spacer.get(0) + spacer.get(i) 35 | // Almost never true, but compiler doesn't know that 36 | if (System.nanoTime() % 10000 == 0L) { 37 | // Side effect that's rarely executed 38 | println("$x") 39 | println(System.identityHashCode(spacer)) 40 | } 41 | } 42 | 43 | // Randomly size the storage buffer so it is not the exact length of an AES key 44 | val part2Size = keySize + CryptographyRandom.nextInt(5, 111) 45 | part2 = ByteBuffer.allocateDirect(part2Size) 46 | val part2Array = ByteArray(part2Size) 47 | CryptographyRandom.nextBytes(part2Array) 48 | part2.put(part2Array) 49 | part2.flip() 50 | 51 | // Now fill the data part with our XOR'd key 52 | for (i in key.indices) { 53 | val xorByte = (key[i].toInt() xor part1.get(i).toInt()).toByte() 54 | part2.put(i, xorByte) 55 | } 56 | } 57 | 58 | /** 59 | * Reconstructs the original key from its XOR-split parts. 60 | * 61 | * @return The reconstructed original key 62 | */ 63 | fun reconstructKey(): ByteArray { 64 | val originalKey = ByteArray(keySize) 65 | for (i in originalKey.indices) { 66 | originalKey[i] = (part1.get(i).toInt() xor part2.get(i).toInt()).toByte() 67 | } 68 | 69 | return originalKey 70 | } 71 | 72 | fun evict() { 73 | part1.zeroOut() 74 | part2.zeroOut() 75 | } 76 | } 77 | 78 | private fun ByteBuffer.zeroOut() { 79 | if (hasArray()) { 80 | val a = array() 81 | val start = arrayOffset() + position() 82 | val end = arrayOffset() + limit() 83 | java.util.Arrays.fill(a, start, end, 0.toByte()) 84 | } else { 85 | val pos = position() 86 | val lim = limit() 87 | for (i in 0 until capacity()) { 88 | put(i, 0) 89 | } 90 | position(pos) 91 | limit(lim) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/deviceInfo.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.provider.Settings 6 | import dev.whyoleg.cryptography.CryptographyProvider 7 | import dev.whyoleg.cryptography.algorithms.SHA512 8 | import dev.whyoleg.cryptography.operations.Hasher 9 | import kotlin.io.encoding.ExperimentalEncodingApi 10 | 11 | class DeviceInfo(private val appContext: Context) { 12 | private val hasher: Hasher = CryptographyProvider.Default.get(SHA512).hasher() 13 | 14 | @OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) 15 | @SuppressLint("HardwareIds") 16 | suspend fun getDeviceIdentifier(): ByteArray { 17 | val androidId = Settings.Secure.getString( 18 | appContext.contentResolver, 19 | Settings.Secure.ANDROID_ID 20 | ) 21 | val id = androidId + android.os.Build.MANUFACTURER + android.os.Build.MODEL 22 | val hashed = hasher.hash(id.toByteArray()) 23 | return hashed 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepository.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security.pin 2 | 3 | import com.darkrockstudios.app.securecamera.preferences.HashedPin 4 | import com.darkrockstudios.app.securecamera.security.SchemeConfig 5 | 6 | interface PinRepository { 7 | suspend fun setAppPin(pin: String, schemeConfig: SchemeConfig) 8 | suspend fun getHashedPin(): HashedPin? 9 | 10 | suspend fun verifySecurityPin(pin: String): Boolean { 11 | val storedHashedPin = getHashedPin() ?: return false 12 | return verifyPin(pin, storedHashedPin) 13 | } 14 | 15 | suspend fun hashPin(pin: String): HashedPin 16 | suspend fun verifyPin(inputPin: String, storedHash: HashedPin): Boolean 17 | suspend fun setPoisonPillPin(pin: String) 18 | suspend fun getPlainPoisonPillPin(): String? 19 | suspend fun getHashedPoisonPillPin(): HashedPin? 20 | suspend fun activatePoisonPill() 21 | suspend fun removePoisonPillPin() 22 | 23 | /** 24 | * Check if a Poison Pill PIN is set 25 | */ 26 | suspend fun hasPoisonPillPin(): Boolean { 27 | return getHashedPin() != null && getHashedPoisonPillPin() != null 28 | } 29 | 30 | /** 31 | * Verify if the input PIN matches the Poison Pill PIN 32 | */ 33 | suspend fun verifyPoisonPillPin(pin: String): Boolean { 34 | val storedHashedPin = getHashedPoisonPillPin() ?: return false 35 | return verifyPin(pin, storedHashedPin) 36 | } 37 | 38 | companion object { 39 | const val ARGON_ITERATIONS = 5 40 | const val ARGON_COST = 65536 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/schemes/EncryptionScheme.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.security.schemes 2 | 3 | import com.darkrockstudios.app.securecamera.preferences.HashedPin 4 | import java.io.File 5 | 6 | /** 7 | * Encryption schemes used to encrypt and decrypt files. 8 | * We have two concrete Schemes: Software and Hardware 9 | */ 10 | interface EncryptionScheme { 11 | /** 12 | * Encrypts plaintext data and writes it to a file. 13 | * This will use the pre-derived key in the cache. 14 | */ 15 | suspend fun encryptToFile(plain: ByteArray, targetFile: File) 16 | 17 | /** 18 | * Encrypts plaintext data using the provided key and writes it to a file 19 | */ 20 | suspend fun encryptToFile(plain: ByteArray, keyBytes: ByteArray, targetFile: File) 21 | 22 | /** 23 | * Encrypts plaintext data using the provided key and returns the ciphered bytes 24 | */ 25 | suspend fun encrypt(plain: ByteArray, keyBytes: ByteArray): ByteArray 26 | 27 | /** 28 | * Encrypts plaintext data using the provided key alias and returns the ciphered bytes 29 | */ 30 | suspend fun encryptWithKeyAlias(plain: ByteArray, keyAlias: String): ByteArray 31 | 32 | /** 33 | * Decrypts ciphered data using the provided key alias and returns the plain text bytes 34 | */ 35 | suspend fun decryptWithKeyAlias(encrypted: ByteArray, keyAlias: String): ByteArray 36 | 37 | /** 38 | * Decrypts an encrypted file and returns the plaintext data 39 | * This will use the pre-derived key in memory. 40 | */ 41 | suspend fun decryptFile(encryptedFile: File): ByteArray 42 | 43 | suspend fun deriveAndCacheKey(plainPin: String, hashedPin: HashedPin) 44 | 45 | /** 46 | * Get the currently derived encryption key, 47 | * or throws if one hasn't been derived yet. 48 | */ 49 | suspend fun getDerivedKey(): ByteArray 50 | 51 | suspend fun deriveKey(plainPin: String, hashedPin: HashedPin): ByteArray 52 | 53 | fun evictKey() 54 | 55 | /** 56 | * First time key creation 57 | */ 58 | suspend fun createKey(plainPin: String, hashedPin: HashedPin) 59 | 60 | suspend fun securityFailureReset() 61 | 62 | fun activatePoisonPill(oldPin: HashedPin?) 63 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/DecoyPhotoExplanationDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.settings 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.Photo 11 | import androidx.compose.material3.* 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.window.Dialog 19 | import com.darkrockstudios.app.securecamera.R 20 | 21 | @Composable 22 | fun DecoyPhotoExplanationDialog( 23 | onDismiss: () -> Unit 24 | ) { 25 | 1337 26 | Dialog(onDismissRequest = onDismiss) { 27 | Card( 28 | modifier = Modifier 29 | .fillMaxWidth() 30 | .padding(16.dp), 31 | shape = MaterialTheme.shapes.medium 32 | ) { 33 | Column( 34 | modifier = Modifier 35 | .fillMaxWidth() 36 | .verticalScroll(rememberScrollState()) 37 | .padding(16.dp), 38 | horizontalAlignment = Alignment.CenterHorizontally 39 | ) { 40 | Icon( 41 | modifier = Modifier 42 | .size(64.dp) 43 | .padding(8.dp), 44 | imageVector = Icons.Filled.Photo, 45 | contentDescription = null, 46 | tint = MaterialTheme.colorScheme.primary 47 | ) 48 | 49 | Text( 50 | text = stringResource(R.string.decoy_explanation_dialog_title), 51 | style = MaterialTheme.typography.headlineSmall, 52 | textAlign = TextAlign.Center, 53 | modifier = Modifier.padding(bottom = 8.dp) 54 | ) 55 | 56 | Text( 57 | text = stringResource(R.string.decoy_explanation_dialog_message), 58 | style = MaterialTheme.typography.bodyMedium, 59 | modifier = Modifier.padding(bottom = 16.dp) 60 | ) 61 | 62 | // OK button 63 | Button( 64 | onClick = onDismiss, 65 | modifier = Modifier 66 | .fillMaxWidth() 67 | .padding(top = 8.dp) 68 | ) { 69 | Text(stringResource(R.string.decoy_explanation_dialog_ok_button)) 70 | } 71 | } 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/LocationDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.settings 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.provider.Settings 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.material3.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import androidx.compose.ui.window.Dialog 14 | import com.darkrockstudios.app.securecamera.LocationPermissionStatus 15 | import com.darkrockstudios.app.securecamera.R 16 | 17 | @Composable 18 | fun LocationDialog( 19 | locationPermissionStatus: LocationPermissionStatus, 20 | context: Context, 21 | onDismiss: () -> Unit 22 | ) { 23 | Dialog(onDismissRequest = onDismiss) { 24 | Card( 25 | modifier = Modifier.Companion 26 | .fillMaxWidth() 27 | .padding(16.dp), 28 | shape = MaterialTheme.shapes.medium 29 | ) { 30 | Column( 31 | modifier = Modifier.Companion 32 | .fillMaxWidth() 33 | .padding(16.dp) 34 | ) { 35 | Text( 36 | text = when (locationPermissionStatus) { 37 | LocationPermissionStatus.DENIED -> stringResource(id = R.string.location_dialog_title_denied) 38 | LocationPermissionStatus.COARSE -> stringResource(id = R.string.location_dialog_title_coarse) 39 | LocationPermissionStatus.FINE -> stringResource(id = R.string.location_dialog_title_fine) 40 | }, 41 | style = MaterialTheme.typography.headlineSmall 42 | ) 43 | 44 | Spacer(modifier = Modifier.Companion.height(8.dp)) 45 | 46 | Text( 47 | text = when (locationPermissionStatus) { 48 | LocationPermissionStatus.DENIED -> stringResource(id = R.string.location_dialog_message_denied) 49 | LocationPermissionStatus.COARSE -> stringResource(id = R.string.location_dialog_message_coarse) 50 | LocationPermissionStatus.FINE -> stringResource(id = R.string.location_dialog_message_fine) 51 | }, 52 | style = MaterialTheme.typography.bodyMedium 53 | ) 54 | 55 | Spacer(modifier = Modifier.Companion.height(16.dp)) 56 | 57 | Row( 58 | modifier = Modifier.Companion.fillMaxWidth(), 59 | horizontalArrangement = Arrangement.End 60 | ) { 61 | TextButton(onClick = onDismiss) { 62 | Text(stringResource(id = R.string.cancel_button)) 63 | } 64 | 65 | Spacer(modifier = Modifier.Companion.width(8.dp)) 66 | 67 | Button( 68 | onClick = { 69 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { 70 | setData(Uri.fromParts("package", context.packageName, null)) 71 | } 72 | context.startActivity(intent) 73 | onDismiss() 74 | } 75 | ) { 76 | Text( 77 | text = stringResource(id = R.string.location_dialog_change_settings) 78 | ) 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/PoisonPillDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.settings 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Info 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.window.Dialog 13 | import com.darkrockstudios.app.securecamera.R 14 | 15 | @Composable 16 | fun PoisonPillDialog( 17 | onDismiss: () -> Unit, 18 | onContinue: () -> Unit 19 | ) { 20 | Dialog(onDismissRequest = onDismiss) { 21 | Card( 22 | modifier = Modifier 23 | .fillMaxWidth() 24 | .padding(16.dp), 25 | shape = MaterialTheme.shapes.medium 26 | ) { 27 | Column( 28 | modifier = Modifier 29 | .fillMaxWidth() 30 | .padding(16.dp) 31 | ) { 32 | Row( 33 | verticalAlignment = Alignment.CenterVertically, 34 | modifier = Modifier.fillMaxWidth() 35 | ) { 36 | Icon( 37 | imageVector = Icons.Filled.Info, 38 | contentDescription = null, 39 | modifier = Modifier.size(24.dp) 40 | ) 41 | Spacer(modifier = Modifier.width(8.dp)) 42 | Text( 43 | text = stringResource(id = R.string.poison_pill_dialog_title), 44 | style = MaterialTheme.typography.headlineSmall 45 | ) 46 | } 47 | 48 | Spacer(modifier = Modifier.height(8.dp)) 49 | 50 | Text( 51 | text = stringResource(id = R.string.poison_pill_dialog_message), 52 | style = MaterialTheme.typography.bodyMedium 53 | ) 54 | 55 | Spacer(modifier = Modifier.height(16.dp)) 56 | 57 | Row( 58 | modifier = Modifier.fillMaxWidth(), 59 | horizontalArrangement = Arrangement.End 60 | ) { 61 | TextButton(onClick = onDismiss) { 62 | Text(stringResource(id = R.string.poison_pill_cancel_button)) 63 | } 64 | 65 | Spacer(modifier = Modifier.width(8.dp)) 66 | 67 | Button( 68 | onClick = onContinue, 69 | ) { 70 | Text( 71 | text = stringResource(id = R.string.poison_pill_continue_button), 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/RemovePoisonPillDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.settings 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Warning 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.saveable.rememberSaveable 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.window.Dialog 17 | import com.darkrockstudios.app.securecamera.R 18 | 19 | @Composable 20 | fun RemovePoisonPillDialog( 21 | onDismiss: () -> Unit, 22 | onConfirm: () -> Unit 23 | ) { 24 | var understandChecked by rememberSaveable { mutableStateOf(false) } 25 | 26 | Dialog(onDismissRequest = onDismiss) { 27 | Card( 28 | modifier = Modifier 29 | .fillMaxWidth() 30 | .padding(16.dp), 31 | shape = MaterialTheme.shapes.medium 32 | ) { 33 | Column( 34 | modifier = Modifier 35 | .fillMaxWidth() 36 | .padding(16.dp) 37 | ) { 38 | Row( 39 | verticalAlignment = Alignment.CenterVertically, 40 | modifier = Modifier.fillMaxWidth() 41 | ) { 42 | Icon( 43 | imageVector = Icons.Filled.Warning, 44 | contentDescription = null, 45 | tint = MaterialTheme.colorScheme.error, 46 | modifier = Modifier.size(24.dp) 47 | ) 48 | Spacer(modifier = Modifier.width(8.dp)) 49 | Text( 50 | text = stringResource(id = R.string.remove_poison_pill_dialog_title), 51 | style = MaterialTheme.typography.headlineSmall 52 | ) 53 | } 54 | 55 | Spacer(modifier = Modifier.height(8.dp)) 56 | 57 | Text( 58 | text = stringResource(id = R.string.remove_poison_pill_dialog_message), 59 | style = MaterialTheme.typography.bodyMedium 60 | ) 61 | 62 | Spacer(modifier = Modifier.height(16.dp)) 63 | 64 | // "I Understand" switch 65 | Row( 66 | modifier = Modifier.fillMaxWidth(), 67 | verticalAlignment = Alignment.CenterVertically 68 | ) { 69 | Text( 70 | text = stringResource(id = R.string.security_reset_understand), 71 | style = MaterialTheme.typography.bodyLarge, 72 | modifier = Modifier.weight(1f) 73 | ) 74 | Switch( 75 | checked = understandChecked, 76 | onCheckedChange = { understandChecked = it } 77 | ) 78 | } 79 | 80 | Spacer(modifier = Modifier.height(16.dp)) 81 | 82 | Row( 83 | modifier = Modifier.fillMaxWidth(), 84 | horizontalArrangement = Arrangement.End 85 | ) { 86 | TextButton(onClick = onDismiss) { 87 | Text(stringResource(id = R.string.cancel_button)) 88 | } 89 | 90 | Spacer(modifier = Modifier.width(8.dp)) 91 | 92 | Button( 93 | onClick = onConfirm, 94 | enabled = understandChecked, 95 | colors = ButtonDefaults.buttonColors( 96 | containerColor = MaterialTheme.colorScheme.error, 97 | contentColor = MaterialTheme.colorScheme.onError 98 | ) 99 | ) { 100 | Text( 101 | text = stringResource(id = R.string.remove_poison_pill_confirm_button), 102 | ) 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/settings/SecurityResetDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.settings 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Warning 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.saveable.rememberSaveable 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.window.Dialog 17 | import com.darkrockstudios.app.securecamera.R 18 | 19 | @Composable 20 | fun SecurityResetDialog( 21 | onDismiss: () -> Unit, 22 | onConfirm: () -> Unit 23 | ) { 24 | var understandChecked by rememberSaveable { mutableStateOf(false) } 25 | 26 | Dialog(onDismissRequest = onDismiss) { 27 | Card( 28 | modifier = Modifier.Companion 29 | .fillMaxWidth() 30 | .padding(16.dp), 31 | shape = MaterialTheme.shapes.medium 32 | ) { 33 | Column( 34 | modifier = Modifier.Companion 35 | .fillMaxWidth() 36 | .padding(16.dp) 37 | ) { 38 | Row( 39 | verticalAlignment = Alignment.CenterVertically, 40 | modifier = Modifier.fillMaxWidth() 41 | ) { 42 | Icon( 43 | imageVector = Icons.Filled.Warning, 44 | contentDescription = null, 45 | tint = MaterialTheme.colorScheme.error, 46 | modifier = Modifier.size(24.dp) 47 | ) 48 | Spacer(modifier = Modifier.width(8.dp)) 49 | Text( 50 | text = stringResource(id = R.string.security_reset_dialog_title), 51 | style = MaterialTheme.typography.headlineSmall 52 | ) 53 | } 54 | 55 | Spacer(modifier = Modifier.Companion.height(8.dp)) 56 | 57 | Text( 58 | text = stringResource(id = R.string.security_reset_dialog_message), 59 | style = MaterialTheme.typography.bodyMedium 60 | ) 61 | 62 | Spacer(modifier = Modifier.Companion.height(16.dp)) 63 | 64 | // "I Understand" switch 65 | Row( 66 | modifier = Modifier.Companion.fillMaxWidth(), 67 | verticalAlignment = Alignment.Companion.CenterVertically 68 | ) { 69 | Text( 70 | text = stringResource(id = R.string.security_reset_understand), 71 | style = MaterialTheme.typography.bodyLarge, 72 | modifier = Modifier.Companion.weight(1f) 73 | ) 74 | Switch( 75 | checked = understandChecked, 76 | onCheckedChange = { understandChecked = it } 77 | ) 78 | } 79 | 80 | Spacer(modifier = Modifier.Companion.height(16.dp)) 81 | 82 | Row( 83 | modifier = Modifier.Companion.fillMaxWidth(), 84 | horizontalArrangement = Arrangement.End 85 | ) { 86 | TextButton(onClick = onDismiss) { 87 | Text(stringResource(id = R.string.cancel_button)) 88 | } 89 | 90 | Spacer(modifier = Modifier.Companion.width(8.dp)) 91 | 92 | Button( 93 | onClick = onConfirm, 94 | enabled = understandChecked, 95 | colors = ButtonDefaults.buttonColors( 96 | containerColor = MaterialTheme.colorScheme.error, 97 | contentColor = MaterialTheme.colorScheme.onError 98 | ) 99 | ) { 100 | Text( 101 | text = stringResource(id = R.string.security_reset_destroy_button), 102 | ) 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/share/ShareUtils.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.share 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import com.darkrockstudios.app.securecamera.camera.PhotoDef 7 | 8 | private const val DECRYPTING_PROVIDER_AUTHORITY = ".decryptingprovider" 9 | 10 | /** 11 | * Creates a URI for a photo using the DecryptingImageProvider 12 | */ 13 | private fun getDecryptingFileProviderUri( 14 | photo: PhotoDef, 15 | context: Context 16 | ): Uri { 17 | val authority = context.packageName + DECRYPTING_PROVIDER_AUTHORITY 18 | return Uri.Builder() 19 | .scheme("content") 20 | .authority(authority) 21 | .path("photos/${photo.photoName}") 22 | .build() 23 | } 24 | 25 | /** 26 | * Share a photo using DecryptingImageProvider (no temp files) 27 | */ 28 | fun sharePhotoWithProvider( 29 | photo: PhotoDef, 30 | context: Context 31 | ): Boolean { 32 | val uri = getDecryptingFileProviderUri(photo, context) 33 | 34 | val shareIntent = Intent().apply { 35 | action = Intent.ACTION_SEND 36 | putExtra(Intent.EXTRA_STREAM, uri) 37 | type = "image/jpeg" 38 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 39 | } 40 | 41 | context.startActivity(Intent.createChooser(shareIntent, "Share Photo")) 42 | return true 43 | } 44 | 45 | /** 46 | * Share multiple photos using DecryptingImageProvider (no temp files) 47 | */ 48 | fun sharePhotosWithProvider( 49 | photos: List, 50 | context: Context 51 | ): Boolean { 52 | if (photos.isEmpty()) { 53 | return false 54 | } 55 | 56 | val uris = photos.map { photo -> 57 | getDecryptingFileProviderUri(photo, context) 58 | } 59 | 60 | val shareIntent = if (uris.size == 1) { 61 | // Single photo share 62 | Intent().apply { 63 | action = Intent.ACTION_SEND 64 | putExtra(Intent.EXTRA_STREAM, uris.first()) 65 | type = "image/jpeg" 66 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 67 | } 68 | } else { 69 | // Multiple photo share 70 | Intent().apply { 71 | action = Intent.ACTION_SEND_MULTIPLE 72 | putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) 73 | type = "image/jpeg" 74 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 75 | } 76 | } 77 | 78 | context.startActivity(Intent.createChooser(shareIntent, "Share Photos")) 79 | return true 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/NotificationPermissionRationale.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui 2 | 3 | import android.Manifest 4 | import android.content.pm.PackageManager 5 | import android.os.Build 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.annotation.StringRes 9 | import androidx.compose.material3.AlertDialog 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TextButton 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.core.content.ContextCompat 19 | import com.darkrockstudios.app.securecamera.R 20 | 21 | @Composable 22 | fun NotificationPermissionRationale( 23 | @StringRes title: Int, 24 | @StringRes text: Int, 25 | ) { 26 | val context = LocalContext.current 27 | 28 | val showNotificationPermissionDialog = remember { mutableStateOf(false) } 29 | 30 | val notificationPermissionLauncher = rememberLauncherForActivityResult( 31 | contract = ActivityResultContracts.RequestPermission() 32 | ) { _ -> 33 | // Noop 34 | } 35 | 36 | // Check if we need to request notification permission (API 33+) 37 | LaunchedEffect(Unit) { 38 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 39 | val notificationPermission = Manifest.permission.POST_NOTIFICATIONS 40 | if (ContextCompat.checkSelfPermission( 41 | context, 42 | notificationPermission 43 | ) != PackageManager.PERMISSION_GRANTED 44 | ) { 45 | showNotificationPermissionDialog.value = true 46 | } 47 | } 48 | } 49 | 50 | // Notification permission dialog (API 33+) 51 | if (showNotificationPermissionDialog.value) { 52 | AlertDialog( 53 | onDismissRequest = { showNotificationPermissionDialog.value = false }, 54 | title = { Text(stringResource(id = title)) }, 55 | text = { Text(stringResource(id = text)) }, 56 | confirmButton = { 57 | TextButton( 58 | onClick = { 59 | showNotificationPermissionDialog.value = false 60 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 61 | notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 62 | } 63 | } 64 | ) { 65 | Text(stringResource(id = R.string.notification_permission_button)) 66 | } 67 | }, 68 | dismissButton = { 69 | TextButton( 70 | onClick = { showNotificationPermissionDialog.value = false } 71 | ) { 72 | Text(stringResource(id = R.string.cancel_button)) 73 | } 74 | } 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/PlayfulScaleVisibility.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.animation.core.FastOutSlowInEasing 5 | import androidx.compose.animation.core.Spring 6 | import androidx.compose.animation.core.spring 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | 11 | @Composable 12 | fun PlayfulScaleVisibility( 13 | isVisible: Boolean, 14 | modifier: Modifier = Modifier, 15 | content: @Composable () -> Unit 16 | ) { 17 | AnimatedVisibility( 18 | modifier = modifier, 19 | visible = isVisible, 20 | enter = scaleIn( 21 | initialScale = 0.7f, 22 | animationSpec = spring( 23 | stiffness = Spring.StiffnessLow, 24 | dampingRatio = Spring.DampingRatioMediumBouncy 25 | ) 26 | ) + fadeIn(), 27 | exit = scaleOut( 28 | targetScale = 0.7f, 29 | animationSpec = tween( 30 | durationMillis = 250, 31 | easing = FastOutSlowInEasing 32 | ) 33 | ) + fadeOut() 34 | ) { 35 | content() 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEvent.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui 2 | 3 | sealed interface UiEvent { 4 | data class ShowSnack( 5 | val text: String, 6 | val action: String? = null 7 | ) : UiEvent 8 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/UiEventHandler.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui 2 | 3 | import androidx.compose.material3.SnackbarHostState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.navigation.NavController 7 | import kotlinx.coroutines.flow.SharedFlow 8 | 9 | @Composable 10 | fun HandleUiEvents(events: SharedFlow, snackbarHostState: SnackbarHostState, navController: NavController) { 11 | LaunchedEffect(Unit) { 12 | events.collect { e -> 13 | when (e) { 14 | is UiEvent.ShowSnack -> { 15 | val result = snackbarHostState.showSnackbar( 16 | message = e.text, 17 | actionLabel = e.action 18 | ) 19 | // handle SnackbarResult if you need to react to “Undo”, etc. 20 | } 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.platform.LocalContext 8 | 9 | private val DarkColorScheme = darkColorScheme( 10 | primary = Purple80, 11 | secondary = PurpleGrey80, 12 | tertiary = Pink80 13 | ) 14 | 15 | private val LightColorScheme = lightColorScheme( 16 | primary = Purple40, 17 | secondary = PurpleGrey40, 18 | tertiary = Pink40 19 | 20 | /* Other default colors to override 21 | background = Color(0xFFFFFBFE), 22 | surface = Color(0xFFFFFBFE), 23 | onPrimary = Color.White, 24 | onSecondary = Color.White, 25 | onTertiary = Color.White, 26 | onBackground = Color(0xFF1C1B1F), 27 | onSurface = Color(0xFF1C1B1F), 28 | */ 29 | ) 30 | 31 | @Composable 32 | fun SecureCameraTheme( 33 | darkTheme: Boolean = isSystemInDarkTheme(), 34 | // Dynamic color is available on Android 12+ 35 | dynamicColor: Boolean = true, 36 | content: @Composable () -> Unit 37 | ) { 38 | val colorScheme = when { 39 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 40 | val context = LocalContext.current 41 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 42 | } 43 | 44 | darkTheme -> DarkColorScheme 45 | else -> LightColorScheme 46 | } 47 | 48 | MaterialTheme( 49 | colorScheme = colorScheme, 50 | typography = Typography, 51 | content = content 52 | ) 53 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 4 | import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource 5 | import com.darkrockstudios.app.securecamera.security.SchemeConfig 6 | import com.darkrockstudios.app.securecamera.security.pin.PinRepository 7 | import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme 8 | 9 | class CreatePinUseCase( 10 | private val authorizationRepository: AuthorizationRepository, 11 | private val encryptionScheme: EncryptionScheme, 12 | private val pinRepository: PinRepository, 13 | private val preferencesDataSource: AppPreferencesDataSource, 14 | ) { 15 | suspend fun createPin(pin: String, schemeConfig: SchemeConfig): Boolean { 16 | pinRepository.setAppPin(pin, schemeConfig) 17 | val hashedPin = authorizationRepository.verifyPin(pin) 18 | return if (hashedPin != null) { 19 | authorizationRepository.createKey(pin, hashedPin) 20 | encryptionScheme.deriveAndCacheKey(pin, hashedPin) 21 | preferencesDataSource.setIntroCompleted(true) 22 | true 23 | } else { 24 | false 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/InvalidateSessionUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 4 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 5 | 6 | class InvalidateSessionUseCase( 7 | private val imageManager: SecureImageRepository, 8 | private val authManager: AuthorizationRepository, 9 | ) { 10 | fun invalidateSession() { 11 | imageManager.evictKey() 12 | imageManager.thumbnailCache.clear() 13 | authManager.revokeAuthorization() 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinSizeUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.security.SecurityLevel 4 | import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector 5 | 6 | class PinSizeUseCase( 7 | val securityLevelDetector: SecurityLevelDetector 8 | ) { 9 | fun getPinSizeRange(level: SecurityLevel = securityLevelDetector.detectSecurityLevel()): IntRange { 10 | return when (level) { 11 | SecurityLevel.TEE, SecurityLevel.STRONGBOX -> (4..16) 12 | SecurityLevel.SOFTWARE -> (6..16) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/PinStrengthCheckUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | class PinStrengthCheckUseCase { 4 | fun isPinStrongEnough(pin: String): Boolean { 5 | // Check if PIN is at least 4 digits long and contains only digits 6 | if (pin.length < 4 || !pin.all { it.isDigit() }) { 7 | return false 8 | } 9 | 10 | // Check if all digits are the same (e.g., "1111") 11 | if (pin.all { it == pin[0] }) { 12 | return false 13 | } 14 | 15 | // Check if PIN is a sequence (ascending or descending) 16 | val isAscendingSequence = (0 until pin.length - 1).all { 17 | pin[it + 1].digitToInt() - pin[it].digitToInt() == 1 18 | } 19 | 20 | val isDescendingSequence = (0 until pin.length - 1).all { 21 | pin[it + 1].digitToInt() - pin[it].digitToInt() == -1 22 | } 23 | 24 | if (isAscendingSequence || isDescendingSequence) { 25 | return false 26 | } 27 | 28 | if (blackList.contains(pin)) { 29 | return false 30 | } 31 | 32 | return true 33 | } 34 | 35 | companion object { 36 | /** 37 | * These are some of the most frequently chosen PINs in data leaks 38 | * that are not already covered by our other heuristics. 39 | */ 40 | val blackList = listOf( 41 | "1212", 42 | "6969", 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/RemovePoisonPillIUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 4 | import com.darkrockstudios.app.securecamera.security.pin.PinRepository 5 | 6 | class RemovePoisonPillIUseCase( 7 | private val pinRepository: PinRepository, 8 | private val imageManager: SecureImageRepository, 9 | ) { 10 | suspend fun removePoisonPill() { 11 | pinRepository.removePoisonPillPin() 12 | imageManager.removeAllDecoyPhotos() 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/SecurityResetUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 4 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 5 | import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme 6 | import timber.log.Timber 7 | 8 | class SecurityResetUseCase( 9 | private val authManager: AuthorizationRepository, 10 | private val imageManager: SecureImageRepository, 11 | private val encryptionScheme: EncryptionScheme, 12 | ) { 13 | suspend fun reset() { 14 | authManager.securityFailureReset() 15 | imageManager.securityFailureReset() 16 | encryptionScheme.securityFailureReset() 17 | Timber.d("Security Reset Complete!") 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.usecases 2 | 3 | import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository 4 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 5 | import com.darkrockstudios.app.securecamera.security.pin.PinRepository 6 | import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme 7 | 8 | class VerifyPinUseCase( 9 | private val authManager: AuthorizationRepository, 10 | private val imageManager: SecureImageRepository, 11 | private val pinRepository: PinRepository, 12 | private val encryptionScheme: EncryptionScheme, 13 | private val migratePinHash: MigratePinHash, 14 | ) { 15 | suspend fun verifyPin(pin: String): Boolean { 16 | migratePinHash.runMigration(pin) 17 | 18 | if (pinRepository.hasPoisonPillPin() && pinRepository.verifyPoisonPillPin(pin)) { 19 | encryptionScheme.activatePoisonPill(oldPin = pinRepository.getHashedPin()) 20 | imageManager.activatePoisonPill() 21 | authManager.activatePoisonPill() 22 | } 23 | 24 | val hashedPin = authManager.verifyPin(pin) 25 | return if (hashedPin != null) { 26 | encryptionScheme.deriveAndCacheKey(pin, hashedPin) 27 | authManager.resetFailedAttempts() 28 | true 29 | } else { 30 | false 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/PhotoInfoDialog.kt: -------------------------------------------------------------------------------- 1 | package com.darkrockstudios.app.securecamera.viewphoto 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material3.AlertDialog 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TextButton 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import com.darkrockstudios.app.securecamera.R 17 | import com.darkrockstudios.app.securecamera.camera.PhotoDef 18 | import com.darkrockstudios.app.securecamera.camera.PhotoMetaData 19 | import com.darkrockstudios.app.securecamera.camera.SecureImageRepository 20 | import org.koin.compose.koinInject 21 | 22 | @Composable 23 | fun PhotoInfoDialog( 24 | photo: PhotoDef, 25 | dismiss: () -> Unit 26 | ) { 27 | val imageManager = koinInject() 28 | 29 | var metadata by remember { mutableStateOf(null) } 30 | 31 | LaunchedEffect(photo) { 32 | metadata = imageManager.getPhotoMetaData(photo) 33 | } 34 | 35 | AlertDialog( 36 | onDismissRequest = dismiss, 37 | title = { Text(stringResource(id = R.string.info_dialog_title)) }, 38 | text = { 39 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 40 | 41 | InfoRow( 42 | title = stringResource(id = R.string.photo_name_label), 43 | value = photo.photoName, 44 | ) 45 | 46 | InfoRow( 47 | title = stringResource(id = R.string.photo_resolution_label), 48 | value = metadata?.resolutionString() ?: stringResource(R.string.photo_no_data), 49 | ) 50 | 51 | InfoRow( 52 | title = stringResource(id = R.string.photo_date_label), 53 | value = metadata?.dateTaken?.toString() ?: stringResource(R.string.photo_no_data), 54 | ) 55 | 56 | InfoRow( 57 | title = stringResource(id = R.string.photo_location_label), 58 | value = metadata?.location?.latLongString ?: stringResource(R.string.photo_no_data), 59 | ) 60 | 61 | InfoRow( 62 | title = stringResource(id = R.string.photo_orientation_label), 63 | value = metadata?.orientation?.toString() ?: stringResource(R.string.photo_no_data), 64 | ) 65 | } 66 | }, 67 | confirmButton = { 68 | TextButton(onClick = dismiss) { 69 | Text(stringResource(id = R.string.ok_button)) 70 | } 71 | } 72 | ) 73 | } 74 | 75 | @Composable 76 | private fun InfoRow( 77 | title: String, 78 | value: String 79 | ) { 80 | Text( 81 | text = title, 82 | style = MaterialTheme.typography.bodyLarge, 83 | color = MaterialTheme.colorScheme.onSurface 84 | ) 85 | Spacer(modifier = Modifier.Companion.height(4.dp)) 86 | 87 | Text( 88 | text = value, 89 | style = MaterialTheme.typography.bodyLarge, 90 | color = MaterialTheme.colorScheme.onSurface 91 | ) 92 | 93 | Spacer(modifier = Modifier.Companion.height(16.dp)) 94 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SecureCamera/SecureCameraAndroid/407fa446b0056b11ef943b926b0418ef0bccc66d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3DDC84 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |