diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index bdb1913a4e..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -# These are supported funding model platforms -custom: ['https://revolut.me/somaldoaca', 'https://www.paypal.com/paypalme/iacobionut01'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..76018dd845 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,61 @@ +name: Build application + +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 21 + cache: gradle + + - name: Run unit tests + run: ./gradlew :app:testDebugUnitTest --no-daemon + + - name: Build instrumentation tests + run: ./gradlew :app:assembleDebugAndroidTest --no-daemon + + - name: Build with Gradle + run: ./gradlew :app:assembleDebug --no-daemon + + media-store-device-test: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 21 + cache: gradle + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run MediaStore paging test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + arch: x86_64 + disable-animations: true + script: >- + ./gradlew :app:connectedDebugAndroidTest + -Pandroid.testInstrumentationRunnerArguments.class=com.dot.gallery.feature_node.data.data_source.mediastore.queries.MediaFlowPagingDeviceTest + --no-daemon diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index fc4aa40b2b..0000000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,179 +0,0 @@ -name: Nightly CI - -on: - push: - branches: - - nightly - -jobs: - build_nomaps: - runs-on: ubuntu-latest - outputs: - versionCode: ${{ steps.versioncode.outputs.versionCode }} - versionName: ${{ steps.versionname.outputs.versionName }} - steps: - - uses: actions/checkout@v6 - - name: Get versionCode - id: versioncode - run: echo "versionCode=$(grep -E '^\s+versionCode\s*=' app/build.gradle.kts | awk '{ print $3 }' | head -n 1)" >> $GITHUB_OUTPUT - - name: Get versionName - id: versionname - run: echo "versionName=$(grep -E '^\s+versionName\s*=' app/build.gradle.kts | head -1 | awk -F\" '{ print $2 }')" >> $GITHUB_OUTPUT - - name: Strip internet permission - run: chmod +x ./strip_permission.sh && ./strip_permission.sh - - name: Disable maps - run: echo "INCLUDE_MAPS=false" >> ./app.properties - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aNoMLRelease - :app:assembleArmeabi-v7aNoMLRelease - :app:assembleX86_64NoMLRelease - :app:assembleX86NoMLRelease - :app:assembleUniversalNoMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (No Maps) - path: app/build/outputs/apk-renamed/**/*.apk - - build_nomaps_withml: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Strip internet permission - run: chmod +x ./strip_permission.sh && ./strip_permission.sh - - name: Disable maps - run: echo "INCLUDE_MAPS=false" >> ./app.properties - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aWithMLRelease - :app:assembleArmeabi-v7aWithMLRelease - :app:assembleX86_64WithMLRelease - :app:assembleX86WithMLRelease - :app:assembleUniversalWithMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (No Maps, WithML) - path: app/build/outputs/apk-renamed/**/*.apk - - build_maps: - runs-on: ubuntu-latest - outputs: - versionCode: ${{ steps.versioncode.outputs.versionCode }} - versionName: ${{ steps.versionname.outputs.versionName }} - steps: - - uses: actions/checkout@v6 - - name: Get versionCode - id: versioncode - run: echo "versionCode=$(grep -E '^\s+versionCode\s*=' app/build.gradle.kts | awk '{ print $3 }' | head -n 1)" >> $GITHUB_OUTPUT - - name: Get versionName - id: versionname - run: echo "versionName=$(grep -E '^\s+versionName\s*=' app/build.gradle.kts | head -1 | awk -F\" '{ print $2 }')" >> $GITHUB_OUTPUT - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aNoMLRelease - :app:assembleArmeabi-v7aNoMLRelease - :app:assembleX86_64NoMLRelease - :app:assembleX86NoMLRelease - :app:assembleUniversalNoMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (Maps) - path: app/build/outputs/apk-renamed/**/*.apk - - release: - needs: [build_nomaps, build_nomaps_withml, build_maps] - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (No Maps) - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (Maps) - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (No Maps, WithML) - - name: Generate SHA256 - run: echo "### Checksums" >> ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-nightly-changelog.txt && find . -name "ReFra-*.apk" -type f -print0 | sort -z | xargs -r0 sha256sum | awk '{gsub(".*/", "", $2); print "**"$2"**:", "`"$1"`"} ' >> ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-nightly-changelog.txt - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v3 - with: - name: ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }} Nightly Release - body_path: ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-nightly-changelog.txt - prerelease: true - tag_name: ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-nightly - target_commitish: ${{ github.sha }} - files: | - ./**/ReFra-*.apk - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml deleted file mode 100644 index e814535794..0000000000 --- a/.github/workflows/stable.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: Release CI - -on: workflow_dispatch - -jobs: - build_nomaps: - runs-on: ubuntu-latest - outputs: - versionCode: ${{ steps.versioncode.outputs.versionCode }} - versionName: ${{ steps.versionname.outputs.versionName }} - steps: - - uses: actions/checkout@v6 - - name: Get versionCode - id: versioncode - run: echo "versionCode=$(grep -E '^\s+versionCode\s*=' app/build.gradle.kts | awk '{ print $3 }' | head -n 1)" >> $GITHUB_OUTPUT - - name: Get versionName - id: versionname - run: echo "versionName=$(grep -E '^\s+versionName\s*=' app/build.gradle.kts | head -1 | awk -F\" '{ print $2 }')" >> $GITHUB_OUTPUT - - name: Strip internet permission - run: chmod +x ./strip_permission.sh && ./strip_permission.sh - - name: Disable maps - run: echo "INCLUDE_MAPS=false" >> ./app.properties - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aNoMLRelease - :app:assembleArmeabi-v7aNoMLRelease - :app:assembleX86_64NoMLRelease - :app:assembleX86NoMLRelease - :app:assembleUniversalNoMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (No Maps) - path: app/build/outputs/apk-renamed/**/*.apk - - build_nomaps_withml: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Strip internet permission - run: chmod +x ./strip_permission.sh && ./strip_permission.sh - - name: Disable maps - run: echo "INCLUDE_MAPS=false" >> ./app.properties - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aWithMLRelease - :app:assembleArmeabi-v7aWithMLRelease - :app:assembleX86_64WithMLRelease - :app:assembleX86WithMLRelease - :app:assembleUniversalWithMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (No Maps, WithML) - path: app/build/outputs/apk-renamed/**/*.apk - - build_maps: - runs-on: ubuntu-latest - outputs: - versionCode: ${{ steps.versioncode.outputs.versionCode }} - versionName: ${{ steps.versionname.outputs.versionName }} - steps: - - uses: actions/checkout@v6 - - name: Get versionCode - id: versioncode - run: echo "versionCode=$(grep -E '^\s+versionCode\s*=' app/build.gradle.kts | awk '{ print $3 }' | head -n 1)" >> $GITHUB_OUTPUT - - name: Get versionName - id: versionname - run: echo "versionName=$(grep -E '^\s+versionName\s*=' app/build.gradle.kts | head -1 | awk -F\" '{ print $2 }')" >> $GITHUB_OUTPUT - - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - cache: 'gradle' - java-version: 21 - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - - name: Make Gradle executable - run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'release_key.jks' - fileDir: 'app/' - encodedString: ${{ secrets.SIGNING_KEY }} - - name: Build Release APK - run: >- - ./gradlew - :app:assembleArm64-v8aNoMLRelease - :app:assembleArmeabi-v7aNoMLRelease - :app:assembleX86_64NoMLRelease - :app:assembleX86NoMLRelease - :app:assembleUniversalNoMLRelease - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (Maps) - path: app/build/outputs/apk-renamed/**/*.apk - - name: Apply Google Play patch - id: gplay_patcher - run: git config --global user.email "paulionut2003@gmail.com" && - git config --global user.name "IacobIonut01" && - git fetch https://github.com/IacobIonut01/ReFra.git main_play && - git cherry-pick 0dbdd73a 2322b135 && - chmod +x ./strip_allfiles_permission.sh && ./strip_allfiles_permission.sh && - printf 'ALL_FILES_ACCESS=false\nINCLUDE_MAPS=true\n' > ./app.properties - - name: Build Google Play Bundle - run: ./gradlew :app:bundleUniversalWithMLGplay - env: - SIGNING_KEY_ALIAS: ${{ secrets.ALIAS }} - SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - SIGNING_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} - - uses: r0adkll/upload-google-play@v1 - continue-on-error: true - with: - serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} - packageName: com.dot.gallery.gplay - releaseFiles: app/build/outputs/bundle/universalWithMLGplay/*.aab - track: production - releaseName: ${{ steps.versionname.outputs.versionName }}-${{ steps.versioncode.outputs.versionCode }} Release - mappingFile: app/build/outputs/mapping/universalWithMLGplay/mapping.txt - debugSymbols: app/build/intermediates/merged_native_libs/universalWithMLGplay/mergeUniversalWithMLGplayNativeLibs/out/lib - - uses: actions/upload-artifact@v5 - with: - name: ReFra Release (GPlay Bundle) - path: app/build/outputs/bundle/universalWithMLGplay/*.aab - - release: - needs: [build_nomaps, build_nomaps_withml, build_maps] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - sparse-checkout: .github/RELEASE_BODY.md - sparse-checkout-cone-mode: false - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (No Maps) - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (Maps) - - uses: actions/download-artifact@v5 - with: - name: ReFra Release (No Maps, WithML) - - name: Generate changelog - run: | - CHANGELOG=${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-changelog.txt - cat .github/RELEASE_BODY.md >> "$CHANGELOG" - echo "" >> "$CHANGELOG" - echo "### Checksums" >> "$CHANGELOG" - find . -name "ReFra-*.apk" -type f -print0 | sort -z | xargs -r0 sha256sum | awk '{gsub(".*/", "", $2); print "**"$2"**:", "`"$1"`"}' >> "$CHANGELOG" - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v3 - with: - name: ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }} Release - body_path: ${{ needs.build_maps.outputs.versionName }}-${{ needs.build_maps.outputs.versionCode }}-changelog.txt - prerelease: false - tag_name: ${{ needs.build_maps.outputs.versionName }} - files: | - ./**/ReFra-*.apk - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml new file mode 100644 index 0000000000..1f95d5adcd --- /dev/null +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -0,0 +1,11 @@ +name: Validate Gradle Wrapper + +on: [pull_request, push] + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: gradle/actions/wrapper-validation@v6 diff --git a/.gitignore b/.gitignore index 2fe94ccf5b..1028aa0ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ api.properties /.kotlin /.vscode **/build -**/.kotlin \ No newline at end of file +**/.kotlin + +*.jks diff --git a/LICENSE b/LICENSE index 7a4a3ea242..e05e6c724f 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026, IacobIonut01 and GrapheneOS Foundation Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 845413c190..8818ecccaa 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,34 @@ -# ReFra -> An Android Gallery app built with Jetpack Compose. -> -> The goal of this project is to create and bring the Gallery app everyone wants, with the features everyone needs. FOSS - -![Downloads](https://img.shields.io/github/downloads/IacobIonut01/Gallery/total?color=%23247EE0&label=Downloads) -[![CI](https://github.com/IacobIonut01/Gallery/actions/workflows/nightly.yml/badge.svg?branch=main)](https://github.com/IacobIonut01/Gallery/actions/workflows/nightly.yml) -![License](https://img.shields.io/github/license/IacobIonut01/Gallery?color=%23247EE0) -[![Crowdin](https://badges.crowdin.net/gallery-compose/localized.svg)](https://crowdin.com/project/gallery-compose) -![GitHub Repo stars](https://img.shields.io/github/stars/IacobIonut01/Gallery?color=%23247EE0) - -![](./screenshots/preview.png) -[![Crowdin](./screenshots/items/support_banner.png)](https://crowdin.com/project/gallery-compose) -[![Community](./screenshots/items/community_banner.png)](https://t.me/GalleryCompose) - -## Download -[Get it on F-Droid](https://f-droid.org/packages/com.dot.gallery) -[Get it on Google Play](https://play.google.com/store/apps/details?id=com.dot.gallery.gplay&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) -[Get it on GitHub](https://github.com/IacobIonut01/Gallery/releases/latest) - -## Support -- Translate the project using the link from above -- Donations: - - Use the links on the right side of the repo (Sponsor me) - - More options available in-app (Settings -> Donate) -## Frequent Questions -- Why 'ReFra'? - - Refra is a short form of 'refraction', which is the bending of light when it passes through different mediums. This app aims to refract our perception of media files, making them more accessible and easier to manage. -- What is the `nomaps` variant? - - The `nomaps` variant is a version of the app that does not include the maps features - Map Preview, Location Map Viewer. This is useful for users who do not need this feature and want to remove unecessary permissions like INTERNET access. -- Why Google Play version is 'Paid'? - - It's just another way to support the project while getting back automatic updates via Google Play -- Why Android 11 is the minimum version required? - - Some Media features and APIs require Android 11 as a minimum version [Trash feature, most APIs used in the app] -- Will you support lower android versions? - - While this is not a priority right now, I do have in mind to include support for lower Android versions at a cost of reduced features. If anyone volntueers to do so before me can request a pull request. -- Can I verify the downloaded APK file? - - Checksums of APKs are provided in the release notes. The signing certificate fingerprint is listed below: - - SHA-256: `78:46:05:DD:50:75:BE:05:82:78:A5:42:5C:BD:E5:21:31:62:CB:B4:59:1B:44:28:F4:4E:75:E0:8C:C6:43:8A` - - SHA-1: `AD:93:69:27:F2:3B:33:99:FC:C0:B2:8A:25:44:C8:1C:AA:42:B0:9A` - - MD5: `73:FC:3C:60:14:D3:69:6D:1B:DA:34:F1:BF:5A:33:3C` -- Will you add [X] feature? - - Please open a new feature request under 'Issues' tab and if the feature will be considered useful and possible can be added. -- Can you remove permission [X]? - - Several permissions (e.g. Internet connectivity, location) are for showing a map preview of your current photo location data. If you do not need this feature, you can download a `nomaps` release from the [Releases page](https://github.com/IacobIonut01/Gallery/releases). +# Gallery + +GrapheneOS Gallery app. + +This project is a fork of [ReFra](https://github.com/IacobIonut01/Gallery), originally developed +by [IacobIonut01](https://github.com/IacobIonut01). + +## About + +Gallery is a modern Android media gallery built with Jetpack Compose. It supports browsing photos and videos, albums, +media details, search, editing, trash, favorites, widgets, and other media-management features. + +## Development + +### Building + +GrapheneOS Gallery is built without all files access and maps, but with ML models. + +To quickly build and stage the release version in the GOS tree, use `scripts/build-and-copy-to-gos.sh`. + +Before running, do `ln -s "$HOME/.android/debug.keystore" app/release_key.jks` to use your debug key for release signing +or create a keystore in `app/release_key.jks`. + +Set `GOS_ROOT` env variable pointing to the root of your GrapheneOS checkout. + +### Running CTS tests + +```shell +atest --test-mapping external/Gallery:all +``` + +## License + +This project is licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 041f424dc9..9ffc6a4936 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ +import com.android.build.api.dsl.ApplicationBuildType import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.io.FileInputStream -import java.util.Properties plugins { alias(libs.plugins.androidApplication) @@ -11,42 +10,32 @@ plugins { alias(libs.plugins.kotlin.compose.compiler) id("kotlin-parcelize") alias(libs.plugins.kotlinSerialization) - id("apk-versioning") } -val abiVersionCodes = mapOf( - "arm64-v8a" to 4, - "armeabi-v7a" to 3, - "x86_64" to 2, - "x86" to 1, - "universal" to 0 -) - -apkVersioning { - flavorVersionCodes.set(abiVersionCodes) - versionCodeMultiplier.set(10) - outputFileName.set("{appName}-{versionName}-{versionCode}{suffix}-{flavorName}-{buildType}") - variables.put("appName", "ReFra") - variables.put("suffix", if (includeMaps) "" else "-nomaps") -} +val baseApplicationId = providers + .gradleProperty("baseApplicationId") + .get() android { namespace = "com.dot.gallery" compileSdk = 37 defaultConfig { - applicationId = "com.dot.gallery" - minSdk = 29 + applicationId = baseApplicationId + minSdk = 36 targetSdk = 37 - versionCode = 42101 - versionName = "4.2.1" + versionCode = 1 + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } - val mapsPrefix = if (includeMaps) "" else "-nomaps" - base.archivesName.set("ReFra-${versionName}-$versionCode$mapsPrefix") + base.archivesName.set("Gallery") + } + + testOptions { + unitTests.isIncludeAndroidResources = true } lint.baseline = file("lint-baseline.xml") @@ -61,23 +50,19 @@ android { } buildTypes { + fun ApplicationBuildType.configureProvider() { + val authority = "$baseApplicationId${applicationIdSuffix.orEmpty()}.media_provider" + manifestPlaceholders["appProvider"] = authority + buildConfigField("String", "CONTENT_AUTHORITY", "\"$authority\"") + } + getByName("debug") { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" - manifestPlaceholders["appProvider"] = "com.dot.gallery.debug.media_provider" - buildConfigField("Boolean", "ALLOW_ALL_FILES_ACCESS", "$allowAllFilesAccess") - buildConfigField("Boolean", "MAPS_ENABLED", "$includeMaps") - buildConfigField( - "String", - "CONTENT_AUTHORITY", - "\"com.dot.gallery.debug.media_provider\"" - ) - buildConfigField("Boolean", "ENABLE_INDEXING", "false") + configureProvider() } getByName("release") { - manifestPlaceholders += mapOf( - "appProvider" to "com.dot.gallery.media_provider" - ) + configureProvider() isMinifyEnabled = true isShrinkResources = true setProguardFiles( @@ -87,10 +72,6 @@ android { ) ) signingConfig = signingConfigs.getByName("release") - buildConfigField("Boolean", "ALLOW_ALL_FILES_ACCESS", "$allowAllFilesAccess") - buildConfigField("Boolean", "MAPS_ENABLED", "$includeMaps") - buildConfigField("String", "CONTENT_AUTHORITY", "\"com.dot.gallery.media_provider\"") - buildConfigField("Boolean", "ENABLE_INDEXING", "true") } create("staging") { initWith(getByName("release")) @@ -99,14 +80,17 @@ android { isShrinkResources = false applicationIdSuffix = ".staging" versionNameSuffix = "-staging" - manifestPlaceholders["appProvider"] = "com.dot.staging.debug.media_provider" - buildConfigField( - "String", - "CONTENT_AUTHORITY", - "\"com.dot.staging.debug.media_provider\"" - ) - buildConfigField("Boolean", "ENABLE_INDEXING", "true") - buildConfigField("Boolean", "MAPS_ENABLED", "$includeMaps") + configureProvider() + } + + // Use to manually check performance with release config, but debug signing + create("perf") { + initWith(getByName("release")) + matchingFallbacks += "release" + applicationIdSuffix = ".perf" + versionNameSuffix = "-perf" + signingConfig = signingConfigs.getByName("debug") + configureProvider() } } @@ -123,6 +107,9 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + androidResources { + noCompress += listOf("json", "onnx", "txt") + } assetPacks += listOf(":ml-models") @@ -133,44 +120,22 @@ android { sourceSets { getByName("main") { - // Conditional maps/nomaps source set - if (includeMaps) { - kotlin.srcDir("src/maps/kotlin") - } else { - kotlin.srcDir("src/nomaps/kotlin") + // For APK builds, include ML assets directly since asset packs are AAB-only + val isBundleBuild = gradle.startParameter.taskNames.any { + it.contains("bundle", ignoreCase = true) } - } - // For withML APK builds, include ML model assets directly - // (asset packs are AAB-only, so for APK builds we inline them) - val isBundleBuild = gradle.startParameter.taskNames.any { - it.contains("bundle", ignoreCase = true) - } - if (!isBundleBuild) { - maybeCreate("withML").apply { - assets.srcDirs("../ml-models/src/main/assets") + if (!isBundleBuild) { + assets.srcDirs("src/main/assets", "../ml-models/src/main/assets") } } } - flavorDimensions += listOf("abi", "ml") - productFlavors { - abiVersionCodes.forEach { (abi, _) -> - create(abi) { - dimension = "abi" - if (abi == "universal") { - ndk.abiFilters.addAll(listOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a")) - } else { - ndk.abiFilters.add(abi) - } - } - } - create("withML") { - dimension = "ml" - buildConfigField("Boolean", "ML_MODELS_BUNDLED", "true") - } - create("noML") { - dimension = "ml" - buildConfigField("Boolean", "ML_MODELS_BUNDLED", "false") + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "x86_64") + isUniversalApk = false } } @@ -260,8 +225,9 @@ dependencies { implementation(libs.room.ktx) // Coders - implementation(libs.jxl.coder.coil) + implementation(libs.androidsvg) implementation(libs.avif.coder.coil) + implementation(libs.jxl.coder.coil) // Sketch implementation(libs.sketch.compose) @@ -290,9 +256,6 @@ dependencies { implementation(libs.androidx.exifinterface) implementation(libs.metadata.extractor) - // NanoHTTPD - Embedded HTTP server for FCast media serving - implementation(libs.nanohttpd) - // Datastore Preferences implementation(libs.datastore.prefs) @@ -309,7 +272,6 @@ dependencies { implementation(libs.androidx.core.splashscreen) // Jetpack Security - implementation(libs.androidx.security.crypto) implementation(libs.androidx.biometric) // Composables - Core @@ -328,40 +290,14 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) - // MapLibre Native SDK - if (includeMaps) { - implementation(libs.maplibre.native) - } - // Tests + testImplementation(libs.androidx.work.testing) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) debugImplementation(libs.compose.ui.tooling) debugRuntimeOnly(libs.compose.ui.test.manifest) } - -val includeMaps: Boolean - get() { - val fl = rootProject.file("app.properties") - return try { - val properties = Properties() - properties.load(FileInputStream(fl)) - properties.getProperty("INCLUDE_MAPS", "true").toBoolean() - } catch (_: Exception) { - true - } - } - -val allowAllFilesAccess: Boolean - get() { - val fl = rootProject.file("app.properties") - - return try { - val properties = Properties() - properties.load(FileInputStream(fl)) - properties.getProperty("ALL_FILES_ACCESS", "true").toBoolean() - } catch (_: Exception) { - true - } - } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3bd5128b75..5d916496d0 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -36,9 +36,9 @@ -dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options$GpuBackend -dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options -# Keep custom Glide decoders and model loaders (HEIF/JXL/Encrypted) +# Keep custom Glide decoders and model loaders (HEIF/JXL) -keep class com.dot.gallery.core.decoder.glide.** { *; } -keep class com.radzivon.bartoshyk.avif.** { *; } -keep class com.awxkee.jxlcoder.** { *; } -dontwarn com.radzivon.bartoshyk.avif.** --dontwarn com.awxkee.jxlcoder.** \ No newline at end of file +-dontwarn com.awxkee.jxlcoder.** diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json index 0fb13d5d84..d3f69ccbe9 100644 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json +++ b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "2fe35fcd34db546153a2ddbaa2e5e76a", + "identityHash": "fa2b674e40b3649f795f7b8d591573ab", "entities": [ { "tableName": "pinned_table", @@ -20,15 +20,1124 @@ "columnNames": [ "id" ] + } + }, + { + "tableName": "blacklist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "wildcard", + "columnName": "wildcard", + "affinity": "TEXT" + }, + { + "fieldPath": "albumIds", + "columnName": "albumIds", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "matchedAlbums", + "columnName": "matchedAlbums", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relativePath", + "columnName": "relativePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumID", + "columnName": "albumID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumLabel", + "columnName": "albumLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryTimestamp", + "columnName": "expiryTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "takenTimestamp", + "columnName": "takenTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "fullDate", + "columnName": "fullDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashed", + "columnName": "trashed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", + "fields": [ + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "version" + ] + } + }, + { + "tableName": "timeline_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupTimelineByMonth", + "columnName": "groupTimelineByMonth", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "groupTimelineInAlbums", + "columnName": "groupTimelineInAlbums", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timelineMediaOrder", + "columnName": "timelineMediaOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" + }, + { + "fieldPath": "albumMediaOrder", + "columnName": "albumMediaOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "classified_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relativePath", + "columnName": "relativePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumID", + "columnName": "albumID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumLabel", + "columnName": "albumLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryTimestamp", + "columnName": "expiryTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "takenTimestamp", + "columnName": "takenTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "fullDate", + "columnName": "fullDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashed", + "columnName": "trashed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT" + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media_metadata_core", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageDescription", + "columnName": "imageDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "dateTimeOriginal", + "columnName": "dateTimeOriginal", + "affinity": "TEXT" + }, + { + "fieldPath": "manufacturerName", + "columnName": "manufacturerName", + "affinity": "TEXT" + }, + { + "fieldPath": "modelName", + "columnName": "modelName", + "affinity": "TEXT" + }, + { + "fieldPath": "aperture", + "columnName": "aperture", + "affinity": "TEXT" + }, + { + "fieldPath": "exposureTime", + "columnName": "exposureTime", + "affinity": "TEXT" + }, + { + "fieldPath": "iso", + "columnName": "iso", + "affinity": "TEXT" + }, + { + "fieldPath": "gpsLatitude", + "columnName": "gpsLatitude", + "affinity": "REAL" + }, + { + "fieldPath": "gpsLongitude", + "columnName": "gpsLongitude", + "affinity": "REAL" + }, + { + "fieldPath": "gpsLocationName", + "columnName": "gpsLocationName", + "affinity": "TEXT" + }, + { + "fieldPath": "gpsLocationNameCountry", + "columnName": "gpsLocationNameCountry", + "affinity": "TEXT" + }, + { + "fieldPath": "gpsLocationNameCity", + "columnName": "gpsLocationNameCity", + "affinity": "TEXT" + }, + { + "fieldPath": "imageWidth", + "columnName": "imageWidth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageHeight", + "columnName": "imageHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageResolutionX", + "columnName": "imageResolutionX", + "affinity": "REAL" + }, + { + "fieldPath": "imageResolutionY", + "columnName": "imageResolutionY", + "affinity": "REAL" + }, + { + "fieldPath": "resolutionUnit", + "columnName": "resolutionUnit", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + } + }, + { + "tableName": "media_metadata_video", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "videoWidth", + "columnName": "videoWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "videoHeight", + "columnName": "videoHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "frameRate", + "columnName": "frameRate", + "affinity": "REAL" + }, + { + "fieldPath": "bitRate", + "columnName": "bitRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + } + }, + { + "tableName": "media_metadata_flags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNightMode", + "columnName": "isNightMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPanorama", + "columnName": "isPanorama", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPhotosphere", + "columnName": "isPhotosphere", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLongExposure", + "columnName": "isLongExposure", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMotionPhoto", + "columnName": "isMotionPhoto", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + } + }, + { + "tableName": "album_thumbnail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUri", + "columnName": "thumbnailUri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId" + ] + } + }, + { + "tableName": "image_embeddings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` BLOB NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "embedding", + "columnName": "embedding", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` BLOB, `referenceImageIds` TEXT NOT NULL DEFAULT '[]', `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchTerms", + "columnName": "searchTerms", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "embedding", + "columnName": "embedding", + "affinity": "BLOB" + }, + { + "fieldPath": "referenceImageIds", + "columnName": "referenceImageIds", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isUserCreated", + "columnName": "isUserCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "similarityScore", + "columnName": "similarityScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isManuallyAdded", + "columnName": "isManuallyAdded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId", + "categoryId" + ] + }, + "indices": [ + { + "name": "index_media_category_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_media_category_mediaId", + "unique": false, + "columnNames": [ + "mediaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" + }, + { + "name": "index_media_category_similarityScore", + "unique": false, + "columnNames": [ + "similarityScore" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" + } + ], + "foreignKeys": [ + { + "table": "categories", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "categoryId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "locked_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "album_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "album_group_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId", + "albumId" + ] }, - "indices": [], - "foreignKeys": [] + "foreignKeys": [ + { + "table": "album_groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "merged_subfolder_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "collections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `coverMediaId` INTEGER, `isPinned` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverMediaId", + "columnName": "coverMediaId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "collection_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `mediaId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `mediaId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "collectionId", + "mediaId" + ] + }, + "indices": [ + { + "name": "index_collection_media_collectionId", + "unique": false, + "columnNames": [ + "collectionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_collectionId` ON `${TABLE_NAME}` (`collectionId`)" + }, + { + "name": "index_collection_media_mediaId", + "unique": false, + "columnNames": [ + "mediaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_mediaId` ON `${TABLE_NAME}` (`mediaId`)" + } + ], + "foreignKeys": [ + { + "table": "collections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "collection_albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `albumId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "collectionId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_collection_albums_collectionId", + "unique": false, + "columnNames": [ + "collectionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_albums_collectionId` ON `${TABLE_NAME}` (`collectionId`)" + }, + { + "name": "index_collection_albums_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_albums_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "collections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collectionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scanned_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "category_classification_generation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `generationId` TEXT NOT NULL, PRIMARY KEY(`singletonId`))", + "fields": [ + { + "fieldPath": "singletonId", + "columnName": "singletonId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generationId", + "columnName": "generationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "singletonId" + ] + } + }, + { + "tableName": "generated_media_category_staging", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generationId` TEXT NOT NULL, `mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`generationId`, `mediaId`, `categoryId`))", + "fields": [ + { + "fieldPath": "generationId", + "columnName": "generationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "similarityScore", + "columnName": "similarityScore", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "generationId", + "mediaId", + "categoryId" + ] + } } ], - "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fe35fcd34db546153a2ddbaa2e5e76a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fa2b674e40b3649f795f7b8d591573ab')" ] } } \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/10.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/10.json deleted file mode 100644 index bd815b9bde..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/10.json +++ /dev/null @@ -1,693 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 10, - "identityHash": "59ea4892e44757f553f14c61eaf1088b", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '59ea4892e44757f553f14c61eaf1088b')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/11.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/11.json deleted file mode 100644 index 369942b2c0..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/11.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 11, - "identityHash": "cb817b5162c214f4ed65f810d7eb814e", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb817b5162c214f4ed65f810d7eb814e')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/12.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/12.json deleted file mode 100644 index 4fa79bcfb8..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/12.json +++ /dev/null @@ -1,738 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 12, - "identityHash": "26ff9ec9cc612eb24d80263f7bb14a7f", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '26ff9ec9cc612eb24d80263f7bb14a7f')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/13.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/13.json deleted file mode 100644 index cac44f4c41..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/13.json +++ /dev/null @@ -1,744 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 13, - "identityHash": "2b64b3e250d8dae20e3cec3ab97553ac", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2b64b3e250d8dae20e3cec3ab97553ac')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/14.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/14.json deleted file mode 100644 index f049c8084f..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/14.json +++ /dev/null @@ -1,899 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 14, - "identityHash": "030b2719f7a9a7826a120e8e0f35c6d7", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `iconEmoji` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "iconEmoji", - "columnName": "iconEmoji", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '030b2719f7a9a7826a120e8e0f35c6d7')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/15.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/15.json deleted file mode 100644 index 3ff62c7318..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/15.json +++ /dev/null @@ -1,894 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 15, - "identityHash": "11e6bf4e85347270a331b6ce4ef1768b", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11e6bf4e85347270a331b6ce4ef1768b')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/16.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/16.json deleted file mode 100644 index 3dc4690ee3..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/16.json +++ /dev/null @@ -1,936 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 16, - "identityHash": "4738f291117a025ffd4fdd42f305db17", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4738f291117a025ffd4fdd42f305db17')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/17.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/17.json deleted file mode 100644 index 357bc504e0..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/17.json +++ /dev/null @@ -1,954 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 17, - "identityHash": "27f461c4b5960eb23809047e82f82449", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '27f461c4b5960eb23809047e82f82449')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/18.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/18.json deleted file mode 100644 index dd645667f7..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/18.json +++ /dev/null @@ -1,1022 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 18, - "identityHash": "79d1c2631dec78b4b92d1865f147fe4c", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_group_members", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "groupId", - "columnName": "groupId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "groupId", - "albumId" - ] - }, - "foreignKeys": [ - { - "table": "album_groups", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "groupId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '79d1c2631dec78b4b92d1865f147fe4c')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/19.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/19.json deleted file mode 100644 index 2e9b872566..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/19.json +++ /dev/null @@ -1,1040 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 19, - "identityHash": "d986f5c74cdebc5b14bc23e589afc438", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_group_members", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "groupId", - "columnName": "groupId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "groupId", - "albumId" - ] - }, - "foreignKeys": [ - { - "table": "album_groups", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "groupId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "merged_subfolder_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd986f5c74cdebc5b14bc23e589afc438')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/2.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/2.json deleted file mode 100644 index 20844acf5e..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/2.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "57402321aff199c16e2f25a4a21623ba", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '57402321aff199c16e2f25a4a21623ba')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/20.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/20.json deleted file mode 100644 index ebb23102a3..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/20.json +++ /dev/null @@ -1,1157 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 20, - "identityHash": "4482c3954f6c26b5d7087d8dc9589e1c", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_group_members", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "groupId", - "columnName": "groupId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "groupId", - "albumId" - ] - }, - "foreignKeys": [ - { - "table": "album_groups", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "groupId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "merged_subfolder_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collections", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `coverMediaId` INTEGER, `isPinned` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "coverMediaId", - "columnName": "coverMediaId", - "affinity": "INTEGER" - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sortOrder", - "columnName": "sortOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collection_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `mediaId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `mediaId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "collectionId", - "columnName": "collectionId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "collectionId", - "mediaId" - ] - }, - "indices": [ - { - "name": "index_collection_media_collectionId", - "unique": false, - "columnNames": [ - "collectionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_collectionId` ON `${TABLE_NAME}` (`collectionId`)" - }, - { - "name": "index_collection_media_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - } - ], - "foreignKeys": [ - { - "table": "collections", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "collectionId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4482c3954f6c26b5d7087d8dc9589e1c')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/21.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/21.json deleted file mode 100644 index 57ec739e1a..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/21.json +++ /dev/null @@ -1,1164 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 21, - "identityHash": "3a4d92718053c0aa2bf79126cc6eaab0", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `referenceImageIds` TEXT NOT NULL DEFAULT '[]', `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "referenceImageIds", - "columnName": "referenceImageIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_group_members", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "groupId", - "columnName": "groupId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "groupId", - "albumId" - ] - }, - "foreignKeys": [ - { - "table": "album_groups", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "groupId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "merged_subfolder_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collections", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `coverMediaId` INTEGER, `isPinned` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "coverMediaId", - "columnName": "coverMediaId", - "affinity": "INTEGER" - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sortOrder", - "columnName": "sortOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collection_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `mediaId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `mediaId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "collectionId", - "columnName": "collectionId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "collectionId", - "mediaId" - ] - }, - "indices": [ - { - "name": "index_collection_media_collectionId", - "unique": false, - "columnNames": [ - "collectionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_collectionId` ON `${TABLE_NAME}` (`collectionId`)" - }, - { - "name": "index_collection_media_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - } - ], - "foreignKeys": [ - { - "table": "collections", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "collectionId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3a4d92718053c0aa2bf79126cc6eaab0')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/22.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/22.json deleted file mode 100644 index b7c393583d..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/22.json +++ /dev/null @@ -1,1228 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 22, - "identityHash": "7b2b1066156f3c9af955c7fd535761b8", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT, `wildcard` TEXT, `albumIds` TEXT NOT NULL DEFAULT '[]', `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT" - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "albumIds", - "columnName": "albumIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `gpsLocationName` TEXT, `gpsLocationNameCountry` TEXT, `gpsLocationNameCity` TEXT, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLocationName", - "columnName": "gpsLocationName", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCountry", - "columnName": "gpsLocationNameCountry", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLocationNameCity", - "columnName": "gpsLocationNameCity", - "affinity": "TEXT" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "album_thumbnail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` INTEGER NOT NULL, `thumbnailUri` TEXT NOT NULL, PRIMARY KEY(`albumId`))", - "fields": [ - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "thumbnailUri", - "columnName": "thumbnailUri", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "albumId" - ] - } - }, - { - "tableName": "image_embeddings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `date` INTEGER NOT NULL, `embedding` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `searchTerms` TEXT NOT NULL, `embedding` TEXT, `referenceImageIds` TEXT NOT NULL DEFAULT '[]', `threshold` REAL NOT NULL, `isUserCreated` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "searchTerms", - "columnName": "searchTerms", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "embedding", - "columnName": "embedding", - "affinity": "TEXT" - }, - { - "fieldPath": "referenceImageIds", - "columnName": "referenceImageIds", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - }, - { - "fieldPath": "threshold", - "columnName": "threshold", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "isUserCreated", - "columnName": "isUserCreated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_category", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `similarityScore` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `isManuallyAdded` INTEGER NOT NULL, PRIMARY KEY(`mediaId`, `categoryId`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "similarityScore", - "columnName": "similarityScore", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isManuallyAdded", - "columnName": "isManuallyAdded", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId", - "categoryId" - ] - }, - "indices": [ - { - "name": "index_media_category_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - }, - { - "name": "index_media_category_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - }, - { - "name": "index_media_category_similarityScore", - "unique": false, - "columnNames": [ - "similarityScore" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_media_category_similarityScore` ON `${TABLE_NAME}` (`similarityScore`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "edited_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `originalUri` TEXT NOT NULL, `backupPath` TEXT NOT NULL, `originalMimeType` TEXT NOT NULL, `editTimestamp` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "originalUri", - "columnName": "originalUri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "backupPath", - "columnName": "backupPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "originalMimeType", - "columnName": "originalMimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "editTimestamp", - "columnName": "editTimestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "locked_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "album_group_members", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, PRIMARY KEY(`groupId`, `albumId`), FOREIGN KEY(`groupId`) REFERENCES `album_groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "groupId", - "columnName": "groupId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "groupId", - "albumId" - ] - }, - "foreignKeys": [ - { - "table": "album_groups", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "groupId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "merged_subfolder_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collections", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `coverMediaId` INTEGER, `isPinned` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "coverMediaId", - "columnName": "coverMediaId", - "affinity": "INTEGER" - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sortOrder", - "columnName": "sortOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "collection_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `mediaId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `mediaId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "collectionId", - "columnName": "collectionId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "collectionId", - "mediaId" - ] - }, - "indices": [ - { - "name": "index_collection_media_collectionId", - "unique": false, - "columnNames": [ - "collectionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_collectionId` ON `${TABLE_NAME}` (`collectionId`)" - }, - { - "name": "index_collection_media_mediaId", - "unique": false, - "columnNames": [ - "mediaId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_media_mediaId` ON `${TABLE_NAME}` (`mediaId`)" - } - ], - "foreignKeys": [ - { - "table": "collections", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "collectionId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "collection_albums", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collectionId` INTEGER NOT NULL, `albumId` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, PRIMARY KEY(`collectionId`, `albumId`), FOREIGN KEY(`collectionId`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "collectionId", - "columnName": "collectionId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "albumId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addedAt", - "columnName": "addedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "collectionId", - "albumId" - ] - }, - "indices": [ - { - "name": "index_collection_albums_collectionId", - "unique": false, - "columnNames": [ - "collectionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_albums_collectionId` ON `${TABLE_NAME}` (`collectionId`)" - }, - { - "name": "index_collection_albums_albumId", - "unique": false, - "columnNames": [ - "albumId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_albums_albumId` ON `${TABLE_NAME}` (`albumId`)" - } - ], - "foreignKeys": [ - { - "table": "collections", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "collectionId" - ], - "referencedColumns": [ - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b2b1066156f3c9af955c7fd535761b8')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json deleted file mode 100644 index 06dd7d8b75..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "b923fda23747db68de4db23fb2360ff8", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b923fda23747db68de4db23fb2360ff8')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json deleted file mode 100644 index 57b91fbe92..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 4, - "identityHash": "d11e7f0b804e55f9526cc63d181876a1", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd11e7f0b804e55f9526cc63d181876a1')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json deleted file mode 100644 index 3c4cb0059c..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json +++ /dev/null @@ -1,258 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 5, - "identityHash": "65c73e58b535f1a24397f4143bdf1dde", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65c73e58b535f1a24397f4143bdf1dde')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/6.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/6.json deleted file mode 100644 index 3aeb006744..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/6.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 6, - "identityHash": "d85fa3d06047d72471daf8b524c6c943", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd85fa3d06047d72471daf8b524c6c943')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/7.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/7.json deleted file mode 100644 index a6558ae80d..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/7.json +++ /dev/null @@ -1,516 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 7, - "identityHash": "190a2664a6d02bbf298fe05a21a21a8c", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '190a2664a6d02bbf298fe05a21a21a8c')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/8.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/8.json deleted file mode 100644 index 3c3a04e156..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/8.json +++ /dev/null @@ -1,669 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 8, - "identityHash": "141b34baea78e63d0e752f1292567ea6", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '141b34baea78e63d0e752f1292567ea6')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/9.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/9.json deleted file mode 100644 index 1fae8e8487..0000000000 --- a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/9.json +++ /dev/null @@ -1,669 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 9, - "identityHash": "141b34baea78e63d0e752f1292567ea6", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "blacklist", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wildcard", - "columnName": "wildcard", - "affinity": "TEXT" - }, - { - "fieldPath": "location", - "columnName": "location", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "matchedAlbums", - "columnName": "matchedAlbums", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'[]'" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "media_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", - "fields": [ - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "version" - ] - } - }, - { - "tableName": "timeline_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "groupTimelineByMonth", - "columnName": "groupTimelineByMonth", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "groupTimelineInAlbums", - "columnName": "groupTimelineInAlbums", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "timelineMediaOrder", - "columnName": "timelineMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - }, - { - "fieldPath": "albumMediaOrder", - "columnName": "albumMediaOrder", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "classified_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, `category` TEXT, `score` REAL NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT" - }, - { - "fieldPath": "score", - "columnName": "score", - "affinity": "REAL", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "encrypted_media", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uuid` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relativePath", - "columnName": "relativePath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumID", - "columnName": "albumID", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumLabel", - "columnName": "albumLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTimestamp", - "columnName": "expiryTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "takenTimestamp", - "columnName": "takenTimestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "fullDate", - "columnName": "fullDate", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mimeType", - "columnName": "mimeType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "favorite", - "columnName": "favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trashed", - "columnName": "trashed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "size", - "columnName": "size", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "vaults", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "media_metadata_core", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `imageDescription` TEXT, `dateTimeOriginal` TEXT, `manufacturerName` TEXT, `modelName` TEXT, `aperture` TEXT, `exposureTime` TEXT, `iso` TEXT, `gpsLatitude` REAL, `gpsLongitude` REAL, `imageWidth` INTEGER NOT NULL, `imageHeight` INTEGER NOT NULL, `imageResolutionX` REAL, `imageResolutionY` REAL, `resolutionUnit` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageDescription", - "columnName": "imageDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "dateTimeOriginal", - "columnName": "dateTimeOriginal", - "affinity": "TEXT" - }, - { - "fieldPath": "manufacturerName", - "columnName": "manufacturerName", - "affinity": "TEXT" - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT" - }, - { - "fieldPath": "aperture", - "columnName": "aperture", - "affinity": "TEXT" - }, - { - "fieldPath": "exposureTime", - "columnName": "exposureTime", - "affinity": "TEXT" - }, - { - "fieldPath": "iso", - "columnName": "iso", - "affinity": "TEXT" - }, - { - "fieldPath": "gpsLatitude", - "columnName": "gpsLatitude", - "affinity": "REAL" - }, - { - "fieldPath": "gpsLongitude", - "columnName": "gpsLongitude", - "affinity": "REAL" - }, - { - "fieldPath": "imageWidth", - "columnName": "imageWidth", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageHeight", - "columnName": "imageHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageResolutionX", - "columnName": "imageResolutionX", - "affinity": "REAL" - }, - { - "fieldPath": "imageResolutionY", - "columnName": "imageResolutionY", - "affinity": "REAL" - }, - { - "fieldPath": "resolutionUnit", - "columnName": "resolutionUnit", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_video", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `durationMs` INTEGER, `videoWidth` INTEGER, `videoHeight` INTEGER, `frameRate` REAL, `bitRate` INTEGER, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "durationMs", - "columnName": "durationMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoWidth", - "columnName": "videoWidth", - "affinity": "INTEGER" - }, - { - "fieldPath": "videoHeight", - "columnName": "videoHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "frameRate", - "columnName": "frameRate", - "affinity": "REAL" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - }, - { - "tableName": "media_metadata_flags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` INTEGER NOT NULL, `isNightMode` INTEGER NOT NULL, `isPanorama` INTEGER NOT NULL, `isPhotosphere` INTEGER NOT NULL, `isLongExposure` INTEGER NOT NULL, `isMotionPhoto` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))", - "fields": [ - { - "fieldPath": "mediaId", - "columnName": "mediaId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isNightMode", - "columnName": "isNightMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPanorama", - "columnName": "isPanorama", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhotosphere", - "columnName": "isPhotosphere", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLongExposure", - "columnName": "isLongExposure", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMotionPhoto", - "columnName": "isMotionPhoto", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "mediaId" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '141b34baea78e63d0e752f1292567ea6')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/debug/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json b/app/schemas/debug/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json deleted file mode 100644 index 0fb13d5d84..0000000000 --- a/app/schemas/debug/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "2fe35fcd34db546153a2ddbaa2e5e76a", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fe35fcd34db546153a2ddbaa2e5e76a')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/release/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json b/app/schemas/release/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json deleted file mode 100644 index 0fb13d5d84..0000000000 --- a/app/schemas/release/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "2fe35fcd34db546153a2ddbaa2e5e76a", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fe35fcd34db546153a2ddbaa2e5e76a')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/staging/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json b/app/schemas/staging/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json deleted file mode 100644 index 0fb13d5d84..0000000000 --- a/app/schemas/staging/com.dot.gallery.feature_node.data.data_source.InternalDatabase/1.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "2fe35fcd34db546153a2ddbaa2e5e76a", - "entities": [ - { - "tableName": "pinned_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fe35fcd34db546153a2ddbaa2e5e76a')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/assets/GalleryDecoderTest.avif b/app/src/androidTest/assets/GalleryDecoderTest.avif new file mode 100644 index 0000000000..a5eef6234b Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.avif differ diff --git a/app/src/androidTest/assets/GalleryDecoderTest.gif b/app/src/androidTest/assets/GalleryDecoderTest.gif new file mode 100644 index 0000000000..15a0a828cb Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.gif differ diff --git a/app/src/androidTest/assets/GalleryDecoderTest.jxl b/app/src/androidTest/assets/GalleryDecoderTest.jxl new file mode 100644 index 0000000000..82fc79f8a3 Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.jxl differ diff --git a/app/src/androidTest/assets/GalleryDecoderTest.mp4 b/app/src/androidTest/assets/GalleryDecoderTest.mp4 new file mode 100644 index 0000000000..0869d5c2f7 Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.mp4 differ diff --git a/app/src/androidTest/assets/GalleryDecoderTest.png b/app/src/androidTest/assets/GalleryDecoderTest.png new file mode 100644 index 0000000000..ccaef3e113 Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.png differ diff --git a/app/src/androidTest/assets/GalleryDecoderTest.svg b/app/src/androidTest/assets/GalleryDecoderTest.svg new file mode 100644 index 0000000000..8516318374 --- /dev/null +++ b/app/src/androidTest/assets/GalleryDecoderTest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/androidTest/assets/GalleryDecoderTest.webp b/app/src/androidTest/assets/GalleryDecoderTest.webp new file mode 100644 index 0000000000..d4bc2b5a4b Binary files /dev/null and b/app/src/androidTest/assets/GalleryDecoderTest.webp differ diff --git a/app/src/androidTest/java/com/dot/gallery/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/dot/gallery/ExampleInstrumentedTest.kt deleted file mode 100644 index 35013b1a5d..0000000000 --- a/app/src/androidTest/java/com/dot/gallery/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.dot.gallery - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.dot.gallery", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/dot/gallery/benchmark/DataProcessingBenchmark.kt b/app/src/androidTest/java/com/dot/gallery/benchmark/DataProcessingBenchmark.kt index 8349b23244..5f86e9ea56 100644 --- a/app/src/androidTest/java/com/dot/gallery/benchmark/DataProcessingBenchmark.kt +++ b/app/src/androidTest/java/com/dot/gallery/benchmark/DataProcessingBenchmark.kt @@ -9,14 +9,14 @@ import com.dot.gallery.benchmark.BenchmarkUtils.logInfo import com.dot.gallery.benchmark.BenchmarkUtils.logSection import com.dot.gallery.benchmark.BenchmarkUtils.logSummaryHeader import com.dot.gallery.core.Constants -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.util.mapMediaToItem +import kotlin.random.Random import kotlinx.coroutines.runBlocking import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters -import kotlin.random.Random /** * Pure algorithm benchmarks using synthetic data at realistic sizes. @@ -260,54 +260,6 @@ class DataProcessingBenchmark { logComparison("HashSet vs find()", current, optimized) } - // ========================================================================== - // 4. Issue #8: locationsMediaFlow — sortedBy per group vs pre-built Map - // ========================================================================== - - @Test - fun test5_locationsMedia_sortedFindVsMapLookup() { - logSection("Issue #8: Locations — sorted+find per group vs Map lookup") - - data class LocationItem(val mediaId: Long, val location: String) - - val media = generateMedia(MEDIA_COUNT) - val locationGroups = List(LOCATION_GROUP_COUNT) { i -> - LocationItem( - mediaId = Random.nextLong(0, MEDIA_COUNT.toLong()), - location = "City_${i % 50}, Country_${i % 20}" - ) - }.groupBy { it.location } - - logInfo("media: ${media.size}, location groups: ${locationGroups.size}") - - // CURRENT: sortedByDescending + find per group - val current = benchmark( - name = "CURRENT — sortedByDescending+find per group", - iterations = 10 - ) { - locationGroups.mapNotNull { (location, items) -> - val found = media - .sortedByDescending { it.definedTimestamp } - .find { it.id == items.first().mediaId } - found?.let { location to it } - } - } - - // OPTIMIZED: pre-built Map, O(1) lookup per group - val optimized = benchmark( - name = "OPTIMIZED — pre-built Map, O(1) lookup", - iterations = 10 - ) { - val mediaMap = HashMap(media.size) - for (m in media) { mediaMap[m.id] = m } - locationGroups.mapNotNull { (location, items) -> - mediaMap[items.first().mediaId]?.let { location to it } - } - } - - logComparison("Map vs sorted+find", current, optimized) - } - // ========================================================================== // 5. Issue #3: Album isPinned — N individual DB queries vs batch Set // ========================================================================== diff --git a/app/src/androidTest/java/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingDeviceTest.kt new file mode 100644 index 0000000000..58d963fb60 --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingDeviceTest.kt @@ -0,0 +1,197 @@ +package com.dot.gallery.core.decoder.glide + +import android.content.ContentResolver +import android.content.ContentValues +import android.graphics.Bitmap +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.dot.gallery.GalleryApp +import com.github.panpf.zoomimage.subsampling.SubsamplingImageGenerateResult +import java.io.File +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okio.buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class GalleryMediaRoutingDeviceTest { + @Test + fun missingDeclaredMimeType_loadsMediaStoreVideoThumbnail() { + val application = ApplicationProvider.getApplicationContext() + val contentResolver = application.contentResolver + val videoUri = insertVideo( + contentResolver = contentResolver, + bytes = readFixture(name = "GalleryDecoderTest.mp4"), + ) + + try { + val bitmap = runBlocking { + withContext(context = Dispatchers.IO) { + Glide.with(application) + .asBitmap() + .load(galleryMediaModel(uri = videoUri, mimeType = null)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + .get() + } + } + + assertEquals(16, bitmap.width) + assertEquals(16, bitmap.height) + bitmap.recycle() + } finally { + contentResolver.delete(videoUri, null, null) + } + } + + @Test + fun subsampling_acceptsVerifiedPlatformImagesAndRejectsSandboxFormats() { + val application = ApplicationProvider.getApplicationContext() + val pngFile = writeFixtureFile( + application = application, + name = "GalleryDecoderTest.png", + ) + val jpegFile = application.cacheDir.resolve("GalleryDecoderTest.jpg") + val avifFile = writeFixtureFile( + application = application, + name = "GalleryDecoderTest.avif", + ) + val jxlFile = writeFixtureFile( + application = application, + name = "GalleryDecoderTest.jxl", + ) + writeJpeg(file = jpegFile) + + try { + runBlocking { + listOf(pngFile, jpegFile).forEach { imageFile -> + val result = generateSubsamplingImage( + application = application, + file = imageFile, + ) + assertTrue(result is SubsamplingImageGenerateResult.Success) + val success = result as SubsamplingImageGenerateResult.Success + val imageSource = success.subsamplingImage.imageSource.create() + imageSource.openSource().buffer().use { source -> + assertTrue(!source.exhausted()) + } + } + + listOf(avifFile, jxlFile).forEach { imageFile -> + val result = generateSubsamplingImage( + application = application, + file = imageFile, + ) + assertTrue(result is SubsamplingImageGenerateResult.Error) + } + } + } finally { + pngFile.delete() + jpegFile.delete() + avifFile.delete() + jxlFile.delete() + } + } + + private suspend fun generateSubsamplingImage( + application: GalleryApp, + file: File, + ): SubsamplingImageGenerateResult { + val generators = galleryMediaSubsamplingImageGenerators() + assertEquals(1, generators.size) + assertTrue(generators.single() is GalleryMediaSubsamplingImageGenerator) + return requireNotNull( + generators.single().generateImage( + context = application, + glide = Glide.get(application), + model = GalleryMediaModel( + uri = Uri.fromFile(file), + declaredMimeType = null, + ), + drawable = ColorDrawable(), + ), + ) + } + + private fun insertVideo(contentResolver: ContentResolver, bytes: ByteArray): Uri { + val uri = requireNotNull( + contentResolver.insert( + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + ContentValues().apply { + put( + MediaStore.MediaColumns.DISPLAY_NAME, + "gallery-routing-${System.nanoTime()}.mp4", + ) + put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") + put(MediaStore.MediaColumns.RELATIVE_PATH, TEST_RELATIVE_PATH) + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ), + ) { "Failed to insert test video" } + + try { + contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(bytes) + } ?: throw IOException("Failed to write test video") + val updatedRows = contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, + null, + null, + ) + check(updatedRows > 0) { "Failed to publish test video" } + return uri + } catch (failure: Exception) { + contentResolver.delete(uri, null, null) + throw failure + } + } + + private fun readFixture(name: String): ByteArray { + val assets = InstrumentationRegistry.getInstrumentation().context.assets + return assets.open(name).use { inputStream -> + inputStream.readBytes() + } + } + + private fun writeFixtureFile(application: GalleryApp, name: String): File { + val fixtureFile = application.cacheDir.resolve(name) + fixtureFile.outputStream().use { outputStream -> + outputStream.write(readFixture(name = name)) + } + return fixtureFile + } + + private fun writeJpeg(file: File) { + val bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888) + try { + file.outputStream().use { outputStream -> + check(bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)) { + "Failed to encode test JPEG" + } + } + } finally { + bitmap.recycle() + } + } + + companion object { + private val TEST_RELATIVE_PATH = + "${Environment.DIRECTORY_MOVIES}/GalleryMediaRoutingTest" + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/core/ml/ManagedOrtSessionRaceTest.kt b/app/src/androidTest/java/com/dot/gallery/core/ml/ManagedOrtSessionRaceTest.kt new file mode 100644 index 0000000000..7605acb269 --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/ml/ManagedOrtSessionRaceTest.kt @@ -0,0 +1,174 @@ +package com.dot.gallery.core.ml + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.FileInputStream +import java.nio.IntBuffer +import java.nio.channels.FileChannel +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ManagedOrtSessionRaceTest { + private val environment = OrtEnvironment.getEnvironment() + + @Test + fun closeWaitsForRunningTextInferenceResultConsumer() { + repeat(ITERATIONS) { + verifyCloseWaitsForRun() + } + } + + private fun verifyCloseWaitsForRun() { + val runEntered = CountDownLatch(1) + val releaseRun = CountDownLatch(1) + val closeReturned = CountDownLatch(1) + val failure = AtomicReference() + val session = openTextSession() + + val runner = Thread( + { + runTextInference( + session = session, + runEntered = runEntered, + releaseRun = releaseRun, + failure = failure, + ) + }, + "managed-ort-session-run", + ) + val closer = Thread( + { + session.close() + closeReturned.countDown() + }, + "managed-ort-session-close", + ) + + runner.start() + assertTrue( + "ONNX run did not enter result consumer", + runEntered.await(10, TimeUnit.SECONDS) + ) + + closer.start() + assertFalse( + "close returned while a run result was still in use", + closeReturned.await(250, TimeUnit.MILLISECONDS) + ) + + releaseRun.countDown() + runner.join(10_000) + closer.join(10_000) + + assertFalse("ONNX run thread did not finish", runner.isAlive) + assertFalse("ONNX close thread did not finish", closer.isAlive) + assertTrue( + "close did not return after run completed", + closeReturned.await(0, TimeUnit.MILLISECONDS) + ) + assertNull("ONNX run failed", failure.get()) + } + + private fun runTextInference( + session: ManagedOrtSession, + runEntered: CountDownLatch, + releaseRun: CountDownLatch, + failure: AtomicReference, + ) { + try { + OnnxTensor + .createTensor(environment, textInput(), TEXT_INPUT_SHAPE) + .use { inputIdsTensor -> + OnnxTensor + .createTensor(environment, attentionMask(), TEXT_INPUT_SHAPE) + .use { attentionMaskTensor -> + val inputs = mapOf( + "input_ids" to inputIdsTensor, + "attention_mask" to attentionMaskTensor, + ) + session.run(inputs = inputs) { result -> + runEntered.countDown() + if (!releaseRun.await(10, TimeUnit.SECONDS)) { + fail("Timed out waiting to release ONNX result") + } + assertEquals("Unexpected ONNX output count", 1, result.size()) + } + } + } + } catch (throwable: Throwable) { + failure.set(throwable) + } + } + + private fun openTextSession(): ManagedOrtSession { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val options = OrtSession.SessionOptions() + var closeOptions = true + try { + context.assets.openFd(TEXT_MODEL_NAME).use { descriptor -> + FileInputStream(descriptor.fileDescriptor).channel.use { channel -> + options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + val model = channel.map( + FileChannel.MapMode.READ_ONLY, + descriptor.startOffset, + descriptor.length, + ) + val session = environment.createSession(model, options) + closeOptions = false + return ManagedOrtSession( + environment = environment, + session = session, + options = options, + ) + } + } + } finally { + if (closeOptions) { + options.close() + } + } + } + + private fun textInput(): IntBuffer { + val buffer = IntBuffer.allocate(TEXT_TOKEN_COUNT) + buffer.put(TOKEN_BOS) + buffer.put(TOKEN_EOS) + while (buffer.position() < TEXT_TOKEN_COUNT) { + buffer.put(0) + } + buffer.flip() + return buffer + } + + private fun attentionMask(): IntBuffer { + val buffer = IntBuffer.allocate(TEXT_TOKEN_COUNT) + buffer.put(1) + buffer.put(1) + while (buffer.position() < TEXT_TOKEN_COUNT) { + buffer.put(0) + } + buffer.flip() + return buffer + } + + private companion object { + private const val ITERATIONS = 10 + private const val TEXT_MODEL_NAME = "textual_quant.onnx" + private const val TEXT_TOKEN_COUNT = 77 + private const val TOKEN_BOS = 49406 + private const val TOKEN_EOS = 49407 + private val TEXT_INPUT_SHAPE = longArrayOf(1, TEXT_TOKEN_COUNT.toLong()) + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/core/sandbox/DecoderFixture.kt b/app/src/androidTest/java/com/dot/gallery/core/sandbox/DecoderFixture.kt new file mode 100644 index 0000000000..c56c966aee --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/sandbox/DecoderFixture.kt @@ -0,0 +1,10 @@ +package com.dot.gallery.core.sandbox + +internal data class DecoderFixture( + val name: String, + val mimeType: String, + val width: Int, + val height: Int, + val decodedWidth: Int, + val decodedHeight: Int, +) diff --git a/app/src/androidTest/java/com/dot/gallery/core/sandbox/IsolatedImageDecoderDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/core/sandbox/IsolatedImageDecoderDeviceTest.kt new file mode 100644 index 0000000000..313db30069 --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/sandbox/IsolatedImageDecoderDeviceTest.kt @@ -0,0 +1,602 @@ +package com.dot.gallery.core.sandbox + +import android.graphics.drawable.AnimatedImageDrawable +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bumptech.glide.Glide +import com.bumptech.glide.Priority +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.dot.gallery.GalleryApp +import com.dot.gallery.core.decoder.SandboxedSketchHeifDecoder +import com.dot.gallery.core.decoder.SandboxedSketchJxlDecoder +import com.dot.gallery.core.decoder.glide.GalleryMediaData +import com.dot.gallery.core.decoder.glide.GalleryMediaModel +import com.dot.gallery.core.decoder.glide.GalleryMediaModelLoader +import com.dot.gallery.core.decoder.glide.GalleryMediaSource +import com.dot.gallery.core.decoder.glide.galleryMediaModel +import com.github.panpf.sketch.BitmapImage +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.request.ImageRequest +import com.github.panpf.sketch.request.RequestContext +import com.github.panpf.sketch.sketch +import com.github.panpf.sketch.source.FileDataSource +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okio.Path.Companion.toOkioPath +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class IsolatedImageDecoderDeviceTest { + private val decoder = ApplicationProvider + .getApplicationContext() + .isolatedImageDecoder + + @After + fun unbindDecoder() { + decoder.unbind() + } + + @Test + fun validAvifAndJxl_decodeRepeatedlyWithExpectedDimensionsAndPixels() { + runBlocking { + listOf( + DecoderFixture( + name = "GalleryDecoderTest.avif", + mimeType = "image/avif", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + DecoderFixture( + name = "GalleryDecoderTest.jxl", + mimeType = "image/jxl", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + ).forEach { fixture -> + val encodedBytes = readFixture(name = fixture.name) + repeat(times = 5) { + val result = decoder.decode( + inputStream = ByteArrayInputStream(encodedBytes), + mimeType = fixture.mimeType, + targetWidth = 64, + targetHeight = 64, + ) + + assertNotNull(result) + val decodedResult = requireNotNull(result) + assertEquals(fixture.width, decodedResult.originalSize.width) + assertEquals(fixture.height, decodedResult.originalSize.height) + assertEquals(fixture.decodedWidth, decodedResult.bitmap.width) + assertEquals(fixture.decodedHeight, decodedResult.bitmap.height) + assertPixelDataIsNotEmpty(result = decodedResult) + decodedResult.bitmap.recycle() + } + } + } + } + + @Test + fun concurrentRequests_completeWithoutReliablePipeFailures() { + runBlocking { + val fixtures = listOf( + DecoderFixture( + name = "GalleryDecoderTest.avif", + mimeType = "image/avif", + width = 4000, + height = 3000, + decodedWidth = 48, + decodedHeight = 36, + ), + DecoderFixture( + name = "GalleryDecoderTest.jxl", + mimeType = "image/jxl", + width = 4000, + height = 3000, + decodedWidth = 48, + decodedHeight = 36, + ), + ) + fixtures.flatMap { fixture -> + val encodedBytes = readFixture(name = fixture.name) + List(size = 4) { + async { + decoder.decode( + inputStream = ByteArrayInputStream(encodedBytes), + mimeType = fixture.mimeType, + targetWidth = 48, + targetHeight = 48, + ) + } + } + }.awaitAll().forEach { result -> + assertNotNull(result) + requireNotNull(result).bitmap.recycle() + } + } + } + + @Test + fun emptyMalformedAndUnsupportedInputs_failClosed() { + runBlocking { + assertNull( + decoder.decode( + inputStream = ByteArrayInputStream(ByteArray(size = 0)), + mimeType = "image/avif", + ), + ) + assertNull( + decoder.decode( + inputStream = ByteArrayInputStream("not an image".encodeToByteArray()), + mimeType = "image/jxl", + ), + ) + assertNull( + decoder.decode( + inputStream = ByteArrayInputStream(readFixture(name = "GalleryDecoderTest.avif")), + mimeType = "image/jpeg", + ), + ) + } + } + + @Test + fun earlyServiceRejection_returnsPromptlyAndPreservesFollowingDecode() { + runBlocking { + val rejectedResult = withTimeout(timeMillis = EARLY_REJECTION_TIMEOUT_MILLIS) { + decoder.decode( + inputStream = ByteArrayInputStream( + ByteArray(size = EARLY_REJECTION_INPUT_BYTES), + ), + mimeType = "image/jpeg", + ) + } + assertNull(rejectedResult) + + val recoveredResult = decoder.decode( + inputStream = ByteArrayInputStream( + readFixture(name = "GalleryDecoderTest.avif"), + ), + mimeType = "image/avif", + targetWidth = 32, + targetHeight = 32, + ) + assertNotNull(recoveredResult) + requireNotNull(recoveredResult).bitmap.recycle() + } + } + + @Test + fun writerFailureAndCancellation_failWithoutHangingTheConnection() { + runBlocking { + assertNull( + decoder.decode( + inputStream = ThrowingInputStream(), + mimeType = "image/avif", + ), + ) + + val cancelledResult = withTimeoutOrNull(timeMillis = 100L) { + decoder.decode( + inputStream = CloseAwareBlockingInputStream(), + mimeType = "image/avif", + ) + } + assertNull(cancelledResult) + + val recoveredResult = decoder.decode( + inputStream = ByteArrayInputStream( + readFixture(name = "GalleryDecoderTest.avif"), + ), + mimeType = "image/avif", + targetWidth = 32, + targetHeight = 32, + ) + assertNotNull(recoveredResult) + requireNotNull(recoveredResult).bitmap.recycle() + } + } + + @Test + fun glideRoute_decodesAvifAndJxlOnlyThroughVerifiedModels() { + runBlocking { + val application = ApplicationProvider.getApplicationContext() + listOf( + DecoderFixture( + name = "GalleryDecoderTest.avif", + mimeType = "image/avif", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + DecoderFixture( + name = "GalleryDecoderTest.jxl", + mimeType = "image/jxl", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + ).forEach { fixture -> + val fixtureFile = application.cacheDir.resolve(fixture.name) + try { + fixtureFile.outputStream().use { outputStream -> + outputStream.write(readFixture(name = fixture.name)) + } + val requestManager = Glide.with(application) + val target = requestManager + .asBitmap() + .load( + galleryMediaModel( + uri = Uri.fromFile(fixtureFile), + mimeType = fixture.mimeType, + ), + ) + .override(64, 64) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val bitmap = withContext(Dispatchers.IO) { target.get() } + assertEquals(fixture.decodedWidth, bitmap.width) + assertEquals(fixture.decodedHeight, bitmap.height) + } finally { + requestManager.clear(target) + } + } finally { + fixtureFile.delete() + } + } + } + } + + @Test + fun glideRoute_routesSandboxSignaturesDeclaredAsVideoByBytes() { + runBlocking { + val application = ApplicationProvider.getApplicationContext() + listOf( + DecoderFixture( + name = "GalleryDecoderTest.avif", + mimeType = "image/avif", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + DecoderFixture( + name = "GalleryDecoderTest.jxl", + mimeType = "image/jxl", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + ).forEach { fixture -> + val fixtureFile = application.cacheDir.resolve(fixture.name) + try { + fixtureFile.outputStream().use { outputStream -> + outputStream.write(readFixture(name = fixture.name)) + } + val requestManager = Glide.with(application) + val target = requestManager + .asBitmap() + .load( + galleryMediaModel( + uri = Uri.fromFile(fixtureFile), + mimeType = "video/mp4", + ), + ) + .override(64, 64) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val bitmap = withContext(Dispatchers.IO) { target.get() } + assertEquals(fixture.decodedWidth, bitmap.width) + assertEquals(fixture.decodedHeight, bitmap.height) + } finally { + requestManager.clear(target) + } + } finally { + fixtureFile.delete() + } + } + } + } + + @Test + fun glideRoute_preservesPlatformAnimationAndVideoDecoders() { + runBlocking { + val application = ApplicationProvider.getApplicationContext() + val pngFile = writeFixtureFile(application = application, name = "GalleryDecoderTest.png") + val gifFile = writeFixtureFile(application = application, name = "GalleryDecoderTest.gif") + val webpFile = writeFixtureFile(application = application, name = "GalleryDecoderTest.webp") + val videoFile = writeFixtureFile(application = application, name = "GalleryDecoderTest.mp4") + try { + withContext(Dispatchers.IO) { + val requestManager = Glide.with(application) + val pngTarget = requestManager + .asBitmap() + .load( + galleryMediaModel( + uri = Uri.fromFile(pngFile), + mimeType = "image/png", + ), + ) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val pngBitmap = pngTarget.get() + assertEquals(16, pngBitmap.width) + assertEquals(16, pngBitmap.height) + } finally { + requestManager.clear(pngTarget) + } + + val gifTarget = requestManager + .asGif() + .load( + galleryMediaModel( + uri = Uri.fromFile(gifFile), + mimeType = "image/gif", + ), + ) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val gifDrawable = gifTarget.get() + assertTrue(gifDrawable.frameCount > 1) + gifDrawable.stop() + } finally { + requestManager.clear(gifTarget) + } + + val webpTarget = requestManager + .asDrawable() + .load( + galleryMediaModel( + uri = Uri.fromFile(webpFile), + mimeType = "image/webp", + ), + ) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val animatedWebp = webpTarget.get() + assertTrue( + "Expected AnimatedImageDrawable, got ${animatedWebp::class.java.name}", + animatedWebp is AnimatedImageDrawable, + ) + (animatedWebp as AnimatedImageDrawable).stop() + } finally { + requestManager.clear(webpTarget) + } + + val videoTarget = requestManager + .asBitmap() + .load( + galleryMediaModel( + uri = Uri.fromFile(videoFile), + mimeType = "video/mp4", + ), + ) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit() + try { + val videoBitmap = videoTarget.get() + assertEquals(16, videoBitmap.width) + assertEquals(16, videoBitmap.height) + } finally { + requestManager.clear(videoTarget) + } + } + } finally { + pngFile.delete() + gifFile.delete() + webpFile.delete() + videoFile.delete() + } + } + } + + @Test + fun galleryMediaLoader_rejectsNonSeekableDeclaredVideo() { + val (readDescriptor, writeDescriptor) = ParcelFileDescriptor.createPipe() + writeDescriptor.close() + val mediaSource = object : GalleryMediaSource { + override fun getMimeType(uri: Uri): String { + return "video/mp4" + } + + override fun openInputStream(uri: Uri): InputStream? { + error("Stream path must not be used for declared video") + } + + override fun openFileDescriptor(uri: Uri): ParcelFileDescriptor { + return readDescriptor + } + } + val loader = GalleryMediaModelLoader(mediaSource = mediaSource) + val loadData = loader.buildLoadData( + model = GalleryMediaModel( + uri = Uri.parse("content://gallery-test/non-seekable-video"), + declaredMimeType = "video/mp4", + ), + width = 64, + height = 64, + options = Options(), + ) + val callback = RecordingMediaCallback() + + loadData.fetcher.loadData(Priority.NORMAL, callback) + + assertNull(callback.data) + assertNotNull(callback.failure) + } + + @Test + fun sketchAdapters_readSizeAndDecodeThroughIsolatedService() { + runBlocking { + val application = ApplicationProvider.getApplicationContext() + listOf( + DecoderFixture( + name = "GalleryDecoderTest.avif", + mimeType = "image/avif", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + DecoderFixture( + name = "GalleryDecoderTest.jxl", + mimeType = "image/jxl", + width = 4000, + height = 3000, + decodedWidth = 64, + decodedHeight = 48, + ), + ).forEach { fixture -> + val fixtureFile = application.cacheDir.resolve(fixture.name) + try { + fixtureFile.outputStream().use { outputStream -> + outputStream.write(readFixture(name = fixture.name)) + } + val request = ImageRequest(application, fixtureFile.toURI().toString()) { + size(width = 64, height = 64) + setExtra(key = "realMimeType", value = fixture.mimeType) + } + val requestContext = RequestContext( + sketch = application.sketch, + request = request, + ) + val dataSource = FileDataSource(path = fixtureFile.toOkioPath()) + val sketchDecoder: Decoder = when (fixture.mimeType) { + "image/avif" -> SandboxedSketchHeifDecoder( + requestContext = requestContext, + dataSource = dataSource, + mimeType = fixture.mimeType, + ) + + else -> SandboxedSketchJxlDecoder( + requestContext = requestContext, + dataSource = dataSource, + ) + } + + val imageInfo = sketchDecoder.getImageInfo() + assertEquals(fixture.width, imageInfo.width) + assertEquals(fixture.height, imageInfo.height) + + val imageData = sketchDecoder.decode() + val bitmap = (imageData.image as BitmapImage).bitmap + assertEquals(fixture.decodedWidth, bitmap.width) + assertEquals(fixture.decodedHeight, bitmap.height) + assertTrue(bitmap.byteCount > 0) + bitmap.recycle() + } finally { + fixtureFile.delete() + } + } + } + } + + private fun readFixture(name: String): ByteArray { + val assets = InstrumentationRegistry.getInstrumentation().context.assets + return assets.open(name).use { inputStream -> + inputStream.readBytes() + } + } + + private fun writeFixtureFile(application: GalleryApp, name: String): File { + val fixtureFile = application.cacheDir.resolve(name) + fixtureFile.outputStream().use { outputStream -> + outputStream.write(readFixture(name = name)) + } + return fixtureFile + } + + private fun assertPixelDataIsNotEmpty(result: IsolatedImageDecodeResult) { + val pixels = IntArray(size = result.bitmap.width * result.bitmap.height) + result.bitmap.getPixels( + pixels, + 0, + result.bitmap.width, + 0, + 0, + result.bitmap.width, + result.bitmap.height, + ) + val firstPixel = pixels.first() + check(pixels.any { pixel -> pixel != firstPixel }) { + "Decoded image contained no varying pixel data" + } + } + + companion object { + private const val EARLY_REJECTION_INPUT_BYTES = 1_000_000 + private const val EARLY_REJECTION_TIMEOUT_MILLIS = 5_000L + } +} + +private class ThrowingInputStream : InputStream() { + override fun read(): Int { + throw IOException("Synthetic writer failure") + } +} + +private class CloseAwareBlockingInputStream : InputStream() { + private val closed = CountDownLatch(1) + + override fun read(): Int { + closed.await() + return -1 + } + + override fun close() { + closed.countDown() + } +} + +private class RecordingMediaCallback : DataFetcher.DataCallback { + var data: GalleryMediaData? = null + private set + var failure: Exception? = null + private set + + override fun onDataReady(data: GalleryMediaData?) { + this.data = data + } + + override fun onLoadFailed(exception: Exception) { + failure = exception + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/core/sandbox/MediaPreviewDecoderDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/core/sandbox/MediaPreviewDecoderDeviceTest.kt new file mode 100644 index 0000000000..7a086880ca --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/sandbox/MediaPreviewDecoderDeviceTest.kt @@ -0,0 +1,417 @@ +package com.dot.gallery.core.sandbox + +import android.content.ContentValues +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.net.Uri +import android.provider.MediaStore +import android.system.Os +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.dot.gallery.GalleryApp +import kotlin.math.abs +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class MediaPreviewDecoderDeviceTest { + private val application = ApplicationProvider.getApplicationContext() + private val contentResolver = application.contentResolver + private val decoder = application.mediaPreviewDecoder + private val insertedUris = mutableListOf() + + @After + fun deleteFixtures() { + insertedUris.forEach { uri -> + contentResolver.delete(uri, null, null) + } + } + + @Test + fun imageAndVideoFixtures_decodeToBoundedNonEmptyPreviews() { + runBlocking { + listOf( + Fixture(name = "GalleryDecoderTest.png", mimeType = "image/png", isVideo = false), + Fixture(name = "GalleryDecoderTest.gif", mimeType = "image/gif", isVideo = false), + Fixture(name = "GalleryDecoderTest.webp", mimeType = "image/webp", isVideo = false), + Fixture(name = "GalleryDecoderTest.svg", mimeType = "image/svg+xml", isVideo = false), + Fixture(name = "GalleryDecoderTest.avif", mimeType = "image/avif", isVideo = false), + Fixture(name = "GalleryDecoderTest.jxl", mimeType = "image/jxl", isVideo = false), + Fixture(name = "GalleryDecoderTest.mp4", mimeType = "video/mp4", isVideo = true), + ).forEach { fixture -> + val uri = insertFixture(fixture = fixture) + val result = decoder.decode( + uri = uri, + mimeType = fixture.mimeType, + isVideo = fixture.isVideo, + ) + + assertNotNull(fixture.name, result) + requireNotNull(result).let { bitmap -> + assertEquals(PREVIEW_SIZE, bitmap.width) + assertEquals(PREVIEW_SIZE, bitmap.height) + assertBitmapHasPixels(bitmap = bitmap) + bitmap.recycle() + } + } + } + } + + @Test + fun recognizedImageSignature_overridesVideoMimeType() { + runBlocking { + val uri = insertFixture( + fixture = Fixture( + name = "GalleryDecoderTest.avif", + mimeType = "video/mp4", + isVideo = true, + ), + ) + + val result = decoder.decode( + uri = uri, + mimeType = "video/mp4", + isVideo = true, + ) + + assertNotNull(result) + requireNotNull(result).recycle() + } + } + + @Test + fun videoLargerThanImageLimit_decodesThumbnail() { + runBlocking { + val uri = insertFixture( + fixture = Fixture( + name = "GalleryDecoderTest.mp4", + mimeType = "video/mp4", + isVideo = true, + ), + minimumSize = LARGE_VIDEO_SIZE_BYTES, + ) + + val result = decoder.decode( + uri = uri, + mimeType = "video/mp4", + isVideo = true, + ) + + assertNotNull(result) + requireNotNull(result).recycle() + } + } + + @Test + fun videoWithUnknownStatSize_decodesThumbnail() { + runBlocking { + val uri = Uri.parse("content://${application.packageName}.unknownsize/video") + + val result = withTimeout(timeMillis = DEVICE_TEST_TIMEOUT_MILLIS) { + decoder.decode( + uri = uri, + mimeType = "video/mp4", + isVideo = true, + ) + } + + assertNotNull(result) + requireNotNull(result).recycle() + } + } + + @Test + fun svgLargerThanLimit_isRejected() { + runBlocking { + val uri = insertFixture( + fixture = Fixture( + name = "GalleryDecoderTest.svg", + mimeType = "image/svg+xml", + isVideo = false, + ), + minimumSize = LARGE_SVG_SIZE_BYTES, + ) + + val result = decoder.decode( + uri = uri, + mimeType = "image/svg+xml", + isVideo = false, + ) + + assertNull(result) + } + } + + @Test + fun nonSquarePlatformImages_decodeToSquarePreviews() { + runBlocking { + listOf( + GeneratedFixture( + name = "GalleryDecoderLandscape.png", + mimeType = "image/png", + width = 640, + height = 320, + format = Bitmap.CompressFormat.PNG, + ), + GeneratedFixture( + name = "GalleryDecoderPortrait.jpg", + mimeType = "image/jpeg", + width = 320, + height = 640, + format = Bitmap.CompressFormat.JPEG, + ), + ).forEach { fixture -> + val uri = insertGeneratedFixture(fixture = fixture) + + val result = decoder.decode( + uri = uri, + mimeType = fixture.mimeType, + isVideo = false, + ) + + assertNotNull(fixture.name, result) + requireNotNull(result).let { bitmap -> + assertEquals(PREVIEW_SIZE, bitmap.width) + assertEquals(PREVIEW_SIZE, bitmap.height) + assertBitmapHasPixels(bitmap = bitmap) + bitmap.recycle() + } + } + } + } + + @Test + fun smallNonSquareImages_preserveCenteredShapeAspectRatio() { + runBlocking { + listOf( + GeneratedFixture( + name = "GalleryDecoderSmallLandscape.png", + mimeType = "image/png", + width = 32, + height = 16, + format = Bitmap.CompressFormat.PNG, + ), + GeneratedFixture( + name = "GalleryDecoderSmallPortrait.png", + mimeType = "image/png", + width = 16, + height = 32, + format = Bitmap.CompressFormat.PNG, + ), + ).forEach { fixture -> + val uri = insertGeneratedFixture( + fixture = fixture, + drawGeometryPattern = true, + ) + + val result = decoder.decode( + uri = uri, + mimeType = fixture.mimeType, + isVideo = false, + ) + + assertNotNull(fixture.name, result) + requireNotNull(result).let { bitmap -> + assertCenteredShapeIsSquare(name = fixture.name, bitmap = bitmap) + bitmap.recycle() + } + } + } + } + + @Test + fun concurrentPreviewRequests_completeInDedicatedIsolatedInstance() { + runBlocking { + val fixtures = listOf( + Fixture(name = "GalleryDecoderTest.png", mimeType = "image/png", isVideo = false), + Fixture(name = "GalleryDecoderTest.avif", mimeType = "image/avif", isVideo = false), + Fixture(name = "GalleryDecoderTest.mp4", mimeType = "video/mp4", isVideo = true), + ).map { fixture -> fixture to insertFixture(fixture = fixture) } + + fixtures.flatMap { (fixture, uri) -> + List(size = 3) { + async { + decoder.decode( + uri = uri, + mimeType = fixture.mimeType, + isVideo = fixture.isVideo, + ) + } + } + }.awaitAll().forEach { result -> + assertNotNull(result) + requireNotNull(result).recycle() + } + } + } + + private fun insertFixture(fixture: Fixture, minimumSize: Long? = null): Uri { + val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val uri = requireNotNull( + contentResolver.insert( + collection, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "analysis_${fixture.name}") + put(MediaStore.MediaColumns.MIME_TYPE, fixture.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/ReFraAnalysisTests") + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ), + ) + insertedUris += uri + contentResolver.openOutputStream(uri, "w").use { outputStream -> + requireNotNull(outputStream).write(readFixture(name = fixture.name)) + } + minimumSize?.let { size -> + contentResolver.openFileDescriptor(uri, "rw").use { descriptor -> + Os.ftruncate(requireNotNull(descriptor).fileDescriptor, size) + } + } + contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, + null, + null, + ) + return uri + } + + private fun insertGeneratedFixture( + fixture: GeneratedFixture, + drawGeometryPattern: Boolean = false, + ): Uri { + val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val uri = requireNotNull( + contentResolver.insert( + collection, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "analysis_${fixture.name}") + put(MediaStore.MediaColumns.MIME_TYPE, fixture.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/ReFraAnalysisTests") + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ), + ) + insertedUris += uri + val bitmap = Bitmap.createBitmap(fixture.width, fixture.height, Bitmap.Config.ARGB_8888) + try { + when { + drawGeometryPattern -> { + bitmap.eraseColor(GEOMETRY_BACKGROUND_COLOR) + Canvas(bitmap).drawCircle( + fixture.width / 2f, + fixture.height / 2f, + minOf(fixture.width, fixture.height) * GEOMETRY_RADIUS_FRACTION, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = GEOMETRY_FOREGROUND_COLOR + }, + ) + } + + else -> bitmap.eraseColor(Color.MAGENTA) + } + contentResolver.openOutputStream(uri, "w").use { outputStream -> + check( + bitmap.compress( + fixture.format, + GENERATED_IMAGE_QUALITY, + requireNotNull(outputStream), + ), + ) + } + } finally { + bitmap.recycle() + } + contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, + null, + null, + ) + return uri + } + + private fun readFixture(name: String): ByteArray { + return InstrumentationRegistry.getInstrumentation().context.assets.open(name).use { input -> + input.readBytes() + } + } + + private fun assertBitmapHasPixels(bitmap: Bitmap) { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels( + pixels, + 0, + bitmap.width, + 0, + 0, + bitmap.width, + bitmap.height, + ) + assertTrue(pixels.any { pixel -> pixel != 0 }) + } + + private fun assertCenteredShapeIsSquare(name: String, bitmap: Bitmap) { + val foregroundCoordinates = buildList { + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + val pixel = bitmap.getPixel(x, y) + if (Color.green(pixel) > Color.red(pixel)) { + add(x to y) + } + } + } + } + assertTrue("$name has no foreground shape", foregroundCoordinates.isNotEmpty()) + val shapeWidth = foregroundCoordinates.maxOf { (x, _) -> x } - + foregroundCoordinates.minOf { (x, _) -> x } + 1 + val shapeHeight = foregroundCoordinates.maxOf { (_, y) -> y } - + foregroundCoordinates.minOf { (_, y) -> y } + 1 + assertTrue( + "$name shape was distorted to ${shapeWidth}x${shapeHeight}", + abs(shapeWidth - shapeHeight) <= GEOMETRY_SIZE_TOLERANCE_PIXELS, + ) + } + + private data class Fixture( + val name: String, + val mimeType: String, + val isVideo: Boolean, + ) + + private data class GeneratedFixture( + val name: String, + val mimeType: String, + val width: Int, + val height: Int, + val format: Bitmap.CompressFormat, + ) + + companion object { + private const val GEOMETRY_BACKGROUND_COLOR = Color.RED + private const val GEOMETRY_FOREGROUND_COLOR = Color.GREEN + private const val GEOMETRY_RADIUS_FRACTION = 0.375f + private const val GEOMETRY_SIZE_TOLERANCE_PIXELS = 8 + private const val GENERATED_IMAGE_QUALITY = 90 + private const val LARGE_SVG_SIZE_BYTES = 17L * 1024L * 1024L + private const val LARGE_VIDEO_SIZE_BYTES = 129L * 1024L * 1024L + private const val PREVIEW_SIZE = 224 + private const val DEVICE_TEST_TIMEOUT_MILLIS = 10_000L + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/core/util/ext/ContentResolverInsertDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/core/util/ext/ContentResolverInsertDeviceTest.kt new file mode 100644 index 0000000000..c16dd27fb5 --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/core/util/ext/ContentResolverInsertDeviceTest.kt @@ -0,0 +1,147 @@ +package com.dot.gallery.core.util.ext + +import android.content.ContentResolver +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.IOException +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ContentResolverInsertDeviceTest { + + private val contentResolver: ContentResolver = + InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + + @Test + fun saveRawStream_publishesOnlyAfterSuccessfulWrite() { + runBlocking { + val displayName = "gallery-pending-${UUID.randomUUID()}.png" + val expectedBytes = byteArrayOf(1, 2, 3, 4) + var destinationUri: Uri? = null + try { + destinationUri = contentResolver.saveRawStream( + writeBlock = { outputStream -> outputStream.write(expectedBytes) }, + mimeType = "image/png", + displayName = displayName, + ) + + assertTrue(destinationUri != null) + val values = queryPublishedValues(uri = requireNotNull(destinationUri)) + assertEquals(0, values.isPending) + assertTrue(values.dateModified > 0L) + val actualBytes = contentResolver.openInputStream(destinationUri)?.use { input -> + input.readBytes() + } + assertArrayEquals(expectedBytes, actualBytes) + } finally { + destinationUri?.let { uri -> contentResolver.delete(uri, null, null) } + } + } + } + + @Test + fun saveRawStream_deletesRowWhenWriteFails() { + runBlocking { + val displayName = "gallery-failed-${UUID.randomUUID()}.png" + + val destinationUri = contentResolver.saveRawStream( + writeBlock = { outputStream -> + outputStream.write(byteArrayOf(1, 2, 3)) + throw IOException("forced failure") + }, + mimeType = "image/png", + displayName = displayName, + ) + + assertNull(destinationUri) + assertEquals(0, countRows(displayName = displayName)) + } + } + + @Test + fun saveRawStream_cancelledBeforePublicationDeletesPendingRow() { + runBlocking { + val displayName = "gallery-cancelled-${UUID.randomUUID()}.png" + val writeStarted = CountDownLatch(1) + val releaseWrite = CountDownLatch(1) + val saveJob = launch(Dispatchers.IO) { + contentResolver.saveRawStream( + writeBlock = { outputStream -> + outputStream.write(byteArrayOf(1, 2, 3)) + writeStarted.countDown() + check(releaseWrite.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + }, + mimeType = "image/png", + displayName = displayName, + ) + } + + assertTrue(writeStarted.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + saveJob.cancel() + releaseWrite.countDown() + withTimeout(TEST_TIMEOUT_MILLIS.milliseconds) { + saveJob.cancelAndJoin() + } + + assertEquals(0, countRows(displayName = displayName)) + } + } + + private fun queryPublishedValues(uri: Uri): PublishedValues { + return contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.IS_PENDING, MediaStore.MediaColumns.DATE_MODIFIED), + null, + null, + )?.use { cursor -> + check(cursor.moveToFirst()) + PublishedValues( + isPending = cursor.getInt(0), + dateModified = cursor.getLong(1), + ) + } ?: error("Inserted media could not be queried") + } + + private fun countRows(displayName: String): Int { + val queryArgs = Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + ) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(displayName)) + } + return contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + queryArgs, + null, + )?.use { cursor -> cursor.count } ?: 0 + } + + private class PublishedValues( + val isPending: Int, + val dateModified: Long, + ) + + private companion object { + private const val TEST_TIMEOUT_MILLIS = 5_000L + private const val TEST_TIMEOUT_SECONDS = 5L + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlowPagingDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlowPagingDeviceTest.kt new file mode 100644 index 0000000000..d9d9a8af6e --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlowPagingDeviceTest.kt @@ -0,0 +1,126 @@ +package com.dot.gallery.feature_node.data.data_source.mediastore.queries + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.dot.gallery.core.util.MediaStoreBuckets +import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery +import com.dot.gallery.feature_node.data.model.Media +import java.util.UUID +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class MediaFlowPagingDeviceTest { + + private val contentResolver: ContentResolver = + InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + + @Test + fun consecutivePages_areAscendingLimitedAndNonOverlapping() { + runBlocking { + val initialHighestId = getHighestMediaId() + val insertedUris = mutableListOf() + try { + repeat(3) { index -> + insertedUris += insertImage(index = index) + } + val insertedIds = insertedUris.map(ContentUris::parseId) + + val firstPage = loadPage(afterId = initialHighestId, limit = 2) + val secondPage = loadPage(afterId = firstPage.last().id, limit = 2) + + assertEquals(insertedIds.take(2), firstPage.map { media -> media.id }) + assertEquals(insertedIds.drop(2), secondPage.map { media -> media.id }) + } finally { + insertedUris.forEach { uri -> contentResolver.delete(uri, null, null) } + } + } + } + + private suspend fun loadPage(afterId: Long, limit: Int): List { + return MediaFlow( + contentResolver = contentResolver, + buckedId = MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id, + skipBatching = true, + afterId = afterId, + limit = limit, + ).flowData().first() + } + + private fun getHighestMediaId(): Long { + val queryArgs = Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SORT_ORDER, + "${MediaStore.Files.FileColumns._ID} DESC", + ) + putInt(ContentResolver.QUERY_ARG_LIMIT, 1) + } + return contentResolver.query( + MediaQuery.MediaStoreFileUri, + arrayOf(MediaStore.Files.FileColumns._ID), + queryArgs, + null, + )?.use { cursor -> + when { + cursor.moveToFirst() -> cursor.getLong(0) + else -> 0L + } + } ?: 0L + } + + private fun insertImage(index: Int): Uri { + val destinationUri = contentResolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + ContentValues().apply { + put( + MediaStore.MediaColumns.DISPLAY_NAME, + "media-flow-${UUID.randomUUID()}-$index.png", + ) + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put(MediaStore.MediaColumns.RELATIVE_PATH, TEST_RELATIVE_PATH) + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ) ?: error("Could not insert MediaStore test image") + return try { + contentResolver.openOutputStream(destinationUri)?.use { outputStream -> + outputStream.write(PNG_SIGNATURE) + } ?: error("Could not open MediaStore test image") + val updatedRows = contentResolver.update( + destinationUri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, + null, + null, + ) + check(updatedRows == 1) { "Could not publish MediaStore test image" } + destinationUri + } catch (exception: Exception) { + contentResolver.delete(destinationUri, null, null) + throw exception + } + } + + private companion object { + private const val TEST_RELATIVE_PATH = "Pictures/ReFraMediaFlowTests" + private val PNG_SIGNATURE = byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ) + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryDeviceTest.kt b/app/src/androidTest/java/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryDeviceTest.kt new file mode 100644 index 0000000000..cfee2d4c9a --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryDeviceTest.kt @@ -0,0 +1,136 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MediaCopyRepositoryDeviceTest { + + @Test + fun copyMedia_publishesByteIdenticalMediaStoreDestination() { + val contentResolver = InstrumentationRegistry.getInstrumentation() + .targetContext + .contentResolver + val sourceBytes = byteArrayOf(1, 3, 5, 7, 9) + var sourceUri: Uri? = null + var destinationUri: Uri? = null + + try { + val insertedSourceUri = insertTestMedia( + contentResolver = contentResolver, + displayName = "media-copy-source-${System.nanoTime()}.jpg", + bytes = sourceBytes, + ) + sourceUri = insertedSourceUri + val repository = MediaCopyRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = Dispatchers.IO, + ) + + val publishedDestinationUri = requireNotNull( + runBlocking { + repository.copyMedia( + sourceUri = insertedSourceUri, + destinationPath = testRelativePath, + onBytesCopied = {}, + ) + }, + ) { "Media copy failed" } + destinationUri = publishedDestinationUri + + assertEquals( + 0, + getPendingState( + contentResolver = contentResolver, + uri = publishedDestinationUri, + ), + ) + assertArrayEquals( + sourceBytes, + readBytes( + contentResolver = contentResolver, + uri = publishedDestinationUri, + ), + ) + } finally { + destinationUri?.let { uri -> + contentResolver.delete(uri, null, null) + } + sourceUri?.let { uri -> + contentResolver.delete(uri, null, null) + } + } + } + + private fun insertTestMedia( + contentResolver: ContentResolver, + displayName: String, + bytes: ByteArray, + ): Uri { + val uri = requireNotNull( + contentResolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put(MediaStore.MediaColumns.RELATIVE_PATH, testRelativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ), + ) { "Failed to insert source test media" } + + try { + contentResolver.openOutputStream(uri)?.use { output -> + output.write(bytes) + } ?: throw IOException("Failed to open source test media") + val publishedRows = contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, + null, + null, + ) + check(publishedRows > 0) { "Failed to publish source test media" } + return uri + } catch (exception: Exception) { + contentResolver.delete(uri, null, null) + throw exception + } + } + + private fun getPendingState(contentResolver: ContentResolver, uri: Uri): Int { + return contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.IS_PENDING), + null, + null, + null, + )?.use { cursor -> + check(cursor.moveToFirst()) { "Destination row is missing" } + cursor.getInt(0) + } ?: error("Failed to query destination row") + } + + private fun readBytes(contentResolver: ContentResolver, uri: Uri): ByteArray { + return contentResolver.openInputStream(uri)?.use { input -> + input.readBytes() + } ?: error("Failed to read destination media") + } + + companion object { + private val testRelativePath = Environment.DIRECTORY_PICTURES + "/GalleryMediaCopyTest" + } +} diff --git a/app/src/androidTest/java/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEntryPointTest.kt b/app/src/androidTest/java/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEntryPointTest.kt new file mode 100644 index 0000000000..b4f00bebd6 --- /dev/null +++ b/app/src/androidTest/java/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEntryPointTest.kt @@ -0,0 +1,44 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import android.content.Intent +import android.provider.MediaStore +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dot.gallery.feature_node.presentation.standalone.StandaloneActivity +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class SecureReviewEntryPointTest { + + @Test + fun standaloneActivity_rejectsExplicitSecureReviewIntent() { + val intent = Intent( + ApplicationProvider.getApplicationContext(), + StandaloneActivity::class.java, + ).apply { + action = MediaStore.ACTION_REVIEW_SECURE + } + + ActivityScenario.launch(intent).use { scenario -> + assertEquals(Lifecycle.State.DESTROYED, scenario.state) + } + } + + @Test + fun secureReviewActivity_rejectsWrongAction() { + val intent = Intent( + ApplicationProvider.getApplicationContext(), + SecureReviewActivity::class.java, + ).apply { + action = Intent.ACTION_VIEW + } + + ActivityScenario.launch(intent).use { scenario -> + assertEquals(Lifecycle.State.DESTROYED, scenario.state) + } + } +} diff --git a/app/src/arm64-v8aRelease/generated/baselineProfiles/baseline-prof.txt b/app/src/arm64-v8aRelease/generated/baselineProfiles/baseline-prof.txt index f1f53a2cf4..f664bdb450 100644 --- a/app/src/arm64-v8aRelease/generated/baselineProfiles/baseline-prof.txt +++ b/app/src/arm64-v8aRelease/generated/baselineProfiles/baseline-prof.txt @@ -18879,25 +18879,6 @@ SPLandroidx/savedstate/internal/SavedStateRegistryImpl$Companion;->()V SPLandroidx/savedstate/internal/SavedStateRegistryImpl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Landroidx/savedstate/internal/SynchronizedObject; SPLandroidx/savedstate/internal/SynchronizedObject;->()V -Landroidx/security/crypto/MasterKey; -SPLandroidx/security/crypto/MasterKey;->(Ljava/lang/String;Ljava/lang/Object;)V -Landroidx/security/crypto/MasterKey$Builder; -SPLandroidx/security/crypto/MasterKey$Builder;->(Landroid/content/Context;)V -SPLandroidx/security/crypto/MasterKey$Builder;->(Landroid/content/Context;Ljava/lang/String;)V -SPLandroidx/security/crypto/MasterKey$Builder;->build()Landroidx/security/crypto/MasterKey; -SPLandroidx/security/crypto/MasterKey$Builder;->setKeyScheme(Landroidx/security/crypto/MasterKey$KeyScheme;)Landroidx/security/crypto/MasterKey$Builder; -Landroidx/security/crypto/MasterKey$Builder$Api23Impl; -SPLandroidx/security/crypto/MasterKey$Builder$Api23Impl;->build(Landroidx/security/crypto/MasterKey$Builder;)Landroidx/security/crypto/MasterKey; -Landroidx/security/crypto/MasterKey$KeyScheme; -SPLandroidx/security/crypto/MasterKey$KeyScheme;->$values()[Landroidx/security/crypto/MasterKey$KeyScheme; -SPLandroidx/security/crypto/MasterKey$KeyScheme;->()V -SPLandroidx/security/crypto/MasterKey$KeyScheme;->(Ljava/lang/String;I)V -Landroidx/security/crypto/MasterKeys; -SPLandroidx/security/crypto/MasterKeys;->()V -SPLandroidx/security/crypto/MasterKeys;->createAES256GCMKeyGenParameterSpec(Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec; -SPLandroidx/security/crypto/MasterKeys;->getOrCreate(Landroid/security/keystore/KeyGenParameterSpec;)Ljava/lang/String; -SPLandroidx/security/crypto/MasterKeys;->keyExists(Ljava/lang/String;)Z -SPLandroidx/security/crypto/MasterKeys;->validate(Landroid/security/keystore/KeyGenParameterSpec;)V Landroidx/sqlite/SQLite; SPLandroidx/sqlite/SQLite;->execSQL(Landroidx/sqlite/SQLiteConnection;Ljava/lang/String;)V Landroidx/sqlite/SQLiteConnection; @@ -22796,49 +22777,16 @@ SPLcom/dot/gallery/core/Settings$Misc$rememberGridSize$1$1$value$1;->invokeSuspe Lcom/dot/gallery/core/SettingsKt; SPLcom/dot/gallery/core/SettingsKt;->()V SPLcom/dot/gallery/core/SettingsKt;->getDataStore(Landroid/content/Context;)Landroidx/datastore/core/DataStore; -Lcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader; -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->handles(Ljava/io/File;)Z -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->handles(Ljava/lang/Object;)Z -Lcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory; -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->build(Lcom/bumptech/glide/load/model/MultiModelLoaderFactory;)Lcom/bumptech/glide/load/model/ModelLoader; -Lcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder; -SPLcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V -Lcom/dot/gallery/core/decoder/glide/EncryptedMediaStream; -Lcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader; -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->handles(Landroid/net/Uri;)Z -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->handles(Ljava/lang/Object;)Z -Lcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory; -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->build(Lcom/bumptech/glide/load/model/MultiModelLoaderFactory;)Lcom/bumptech/glide/load/model/ModelLoader; -Lcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder; -SPLcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;Lkotlin/jvm/functions/Function0;)V Lcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder; SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->()V SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->handles(Ljava/io/InputStream;Lcom/bumptech/glide/load/Options;)Z SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->handles(Ljava/lang/Object;Lcom/bumptech/glide/load/Options;)Z -Lcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder; -SPLcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V -Lcom/dot/gallery/core/decoder/glide/IsEncryptedVaultFileKt; -SPLcom/dot/gallery/core/decoder/glide/IsEncryptedVaultFileKt;->isEncryptedVaultFile(Ljava/io/File;)Z Lcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder; SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->()V SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->handles(Ljava/io/InputStream;Lcom/bumptech/glide/load/Options;)Z SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->handles(Ljava/lang/Object;Lcom/bumptech/glide/load/Options;)Z -Lcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder; -SPLcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V Lcom/dot/gallery/core/presentation/components/AppBarKt; SPLcom/dot/gallery/core/presentation/components/AppBarKt;->$r8$lambda$DsPR7b3BsTxlzkrJf3l-Kymmt-Y(I)I SPLcom/dot/gallery/core/presentation/components/AppBarKt;->$r8$lambda$Vay4j8nd0PNlEP9MEQJtcWNVJKY(Ljava/util/List;Landroidx/navigation/NavController;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/State;Landroidx/compose/animation/AnimatedVisibilityScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; @@ -23530,7 +23478,6 @@ Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$15QqL12KsHwPyPiG3qbiCHi8Dhs(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MetadataDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$2QDUjMU4Hx8kYc7tO3hlvnIgsW8(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$711IgaQTzxOzDjGMNmIWCtjJyBU(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MediaDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$A6DCbF69_hYXBjdmUra8K7PT9YI(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; PLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$L85sszyn6t4C4HmsRrdgfiM6Ac0(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/ClassifierDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$ZfOKcFvP4FXN5mKwqbyGoFRl4-A(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/BlacklistDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$w69ZHrj2x99hkAuAipMHFUH2Pew(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao_Impl; @@ -23544,7 +23491,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_imageE SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_mediaDao$lambda$2(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MediaDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_metadataDao$lambda$5(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MetadataDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_pinnedDao$lambda$0(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_vaultDao$lambda$4(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->access$internalInitInvalidationTracker(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;Landroidx/sqlite/SQLiteConnection;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->createAutoMigrations(Ljava/util/Map;)Ljava/util/List; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->createInvalidationTracker()Landroidx/room/InvalidationTracker; @@ -23559,7 +23505,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getMeta SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getPinnedDao()Lcom/dot/gallery/feature_node/data/data_source/PinnedDao; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getRequiredAutoMigrationSpecClasses()Ljava/util/Set; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getRequiredTypeConverterClasses()Ljava/util/Map; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getVaultDao()Lcom/dot/gallery/feature_node/data/data_source/VaultDao; Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0;->(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0;->invoke()Ljava/lang/Object; @@ -23587,12 +23532,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$External Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1;->(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1;->onOpen(Landroidx/sqlite/SQLiteConnection;)V -Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder;->()V -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder;->(Landroid/content/Context;)V -Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion; -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion;->()V -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/dot/gallery/feature_node/data/data_source/MediaDao; PLcom/dot/gallery/feature_node/data/data_source/MediaDao;->updateMedia$suspendImpl(Lcom/dot/gallery/feature_node/data/data_source/MediaDao;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; PLcom/dot/gallery/feature_node/data/data_source/MediaDao;->updateMedia(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -23752,27 +23691,6 @@ Lcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion; SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->()V SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->getRequiredConverters()Ljava/util/List; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->()V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->(Landroidx/room/RoomDatabase;)V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->getVaults()Lkotlinx/coroutines/flow/Flow; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$$ExternalSyntheticLambda4; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$$ExternalSyntheticLambda4;->(Ljava/lang/String;)V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$1; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$1;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$2; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$2;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$3; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$3;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$4; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$4;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$5; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$5;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->()V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->getRequiredConverters()Ljava/util/List; Lcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery; SPLcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery;->()V SPLcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery;->()V @@ -23820,7 +23738,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/mediastore/queries/QueryFlow;-> SPLcom/dot/gallery/feature_node/data/data_source/mediastore/queries/QueryFlow;->()V Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->()V -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)V SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->access$getDatabase$p(Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getAlbumThumbnails()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getAlbums(Lcom/dot/gallery/feature_node/domain/util/MediaOrder;)Lkotlinx/coroutines/flow/Flow; @@ -23834,7 +23751,6 @@ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getPinnedA SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getSetting(Landroidx/datastore/preferences/core/Preferences$Key;Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getTimelineSettings()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getTrashed()Lkotlinx/coroutines/flow/Flow; -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getVaults()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->updateInternalDatabase(Lkotlin/coroutines/Continuation;)Ljava/lang/Object; Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$Companion; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$Companion;->()V @@ -23894,8 +23810,6 @@ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getSetting$$ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getSetting$$inlined$map$1$2$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getTrashed$$inlined$map$1; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getTrashed$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;)V -Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getVaults$$inlined$map$1; -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getVaults$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;)V Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1;->(Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;Lkotlin/coroutines/Continuation;)V SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; @@ -24071,10 +23985,6 @@ Lcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase; SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->()V SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->()V SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->equals(Ljava/lang/Object;)Z -Lcom/dot/gallery/feature_node/domain/model/VaultState; -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->()V -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->(Ljava/util/List;Z)V -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->(Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; Lcom/dot/gallery/feature_node/domain/util/Converters; SPLcom/dot/gallery/feature_node/domain/util/Converters;->()V @@ -25134,7 +25044,6 @@ SPLcom/dot/gallery/feature_node/presentation/util/AppBottomSheetStateKt$$Externa Lcom/dot/gallery/feature_node/presentation/util/ContextExtKt; SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->()V SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->getMediaStoreVersion(Landroid/content/Context;)Ljava/lang/String; -SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isManageFilesAllowed()Z PLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isMediaUpToDate(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isMetadataUpToDate(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; PLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->launchManageMedia(Landroid/content/Context;)V @@ -25264,9 +25173,6 @@ SPLcom/dot/gallery/feature_node/presentation/util/Screen$TimelineScreen;-> Lcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen; SPLcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen;->()V SPLcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen;->()V -Lcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen; -SPLcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen;->()V -SPLcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen;->()V Lcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt; SPLcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt;->mediaSharedElement$lambda$4$lambda$0(Landroidx/compose/runtime/MutableState;)Z SPLcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt;->mediaSharedElement(Landroidx/compose/animation/SharedTransitionScope;Landroidx/compose/ui/Modifier;ZLcom/dot/gallery/feature_node/domain/model/Media;Landroidx/compose/animation/AnimatedVisibilityScope;Landroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; @@ -25305,21 +25211,13 @@ SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;-> SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules$KeyModule; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules$KeyModule;->provide()Z -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_BindsModule_Binds_LazyMapKey; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_KeyModule_Provide_LazyMapKey; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V Lcom/dot/gallery/injection/AppModule; SPLcom/dot/gallery/injection/AppModule;->()V SPLcom/dot/gallery/injection/AppModule;->()V SPLcom/dot/gallery/injection/AppModule;->provideDatabase(Landroid/app/Application;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; SPLcom/dot/gallery/injection/AppModule;->provideEventHandler()Lcom/dot/gallery/feature_node/domain/util/EventHandler; -SPLcom/dot/gallery/injection/AppModule;->provideKeychainHolder(Landroid/content/Context;)Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; SPLcom/dot/gallery/injection/AppModule;->provideMediaDistributor(Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;Lcom/dot/gallery/feature_node/domain/util/EventHandler;)Lcom/dot/gallery/core/MediaDistributor; SPLcom/dot/gallery/injection/AppModule;->provideMediaHandler(Landroid/content/Context;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;)Lcom/dot/gallery/core/MediaHandler; -SPLcom/dot/gallery/injection/AppModule;->provideMediaRepository(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; SPLcom/dot/gallery/injection/AppModule;->provideMediaSelector()Lcom/dot/gallery/core/MediaSelector; SPLcom/dot/gallery/injection/AppModule;->provideSearchHelper(Landroid/content/Context;)Lcom/dot/gallery/feature_node/presentation/search/SearchHelper; SPLcom/dot/gallery/injection/AppModule;->provideWorkManager(Landroid/content/Context;)Landroidx/work/WorkManager; @@ -25327,14 +25225,11 @@ Lcom/dot/gallery/injection/AppModule_ProvideDatabaseFactory; SPLcom/dot/gallery/injection/AppModule_ProvideDatabaseFactory;->provideDatabase(Landroid/app/Application;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; Lcom/dot/gallery/injection/AppModule_ProvideEventHandlerFactory; SPLcom/dot/gallery/injection/AppModule_ProvideEventHandlerFactory;->provideEventHandler()Lcom/dot/gallery/feature_node/domain/util/EventHandler; -Lcom/dot/gallery/injection/AppModule_ProvideKeychainHolderFactory; -SPLcom/dot/gallery/injection/AppModule_ProvideKeychainHolderFactory;->provideKeychainHolder(Landroid/content/Context;)Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; Lcom/dot/gallery/injection/AppModule_ProvideMediaDistributorFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaDistributorFactory;->provideMediaDistributor(Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;Lcom/dot/gallery/feature_node/domain/util/EventHandler;)Lcom/dot/gallery/core/MediaDistributor; Lcom/dot/gallery/injection/AppModule_ProvideMediaHandlerFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaHandlerFactory;->provideMediaHandler(Landroid/content/Context;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;)Lcom/dot/gallery/core/MediaHandler; Lcom/dot/gallery/injection/AppModule_ProvideMediaRepositoryFactory; -SPLcom/dot/gallery/injection/AppModule_ProvideMediaRepositoryFactory;->provideMediaRepository(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; Lcom/dot/gallery/injection/AppModule_ProvideMediaSelectorFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaSelectorFactory;->provideMediaSelector()Lcom/dot/gallery/core/MediaSelector; Lcom/dot/gallery/injection/AppModule_ProvideSearchHelperFactory; @@ -29376,4 +29271,4 @@ Lokio/internal/ResourceFileSystem$$ExternalSyntheticLambda1; SPLokio/internal/ResourceFileSystem$$ExternalSyntheticLambda1;->(Lokio/internal/ResourceFileSystem;)V Lokio/internal/ResourceFileSystem$Companion; SPLokio/internal/ResourceFileSystem$Companion;->()V -SPLokio/internal/ResourceFileSystem$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V \ No newline at end of file +SPLokio/internal/ResourceFileSystem$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/app/src/arm64-v8aRelease/generated/baselineProfiles/startup-prof.txt b/app/src/arm64-v8aRelease/generated/baselineProfiles/startup-prof.txt index f1f53a2cf4..f664bdb450 100644 --- a/app/src/arm64-v8aRelease/generated/baselineProfiles/startup-prof.txt +++ b/app/src/arm64-v8aRelease/generated/baselineProfiles/startup-prof.txt @@ -18879,25 +18879,6 @@ SPLandroidx/savedstate/internal/SavedStateRegistryImpl$Companion;->()V SPLandroidx/savedstate/internal/SavedStateRegistryImpl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Landroidx/savedstate/internal/SynchronizedObject; SPLandroidx/savedstate/internal/SynchronizedObject;->()V -Landroidx/security/crypto/MasterKey; -SPLandroidx/security/crypto/MasterKey;->(Ljava/lang/String;Ljava/lang/Object;)V -Landroidx/security/crypto/MasterKey$Builder; -SPLandroidx/security/crypto/MasterKey$Builder;->(Landroid/content/Context;)V -SPLandroidx/security/crypto/MasterKey$Builder;->(Landroid/content/Context;Ljava/lang/String;)V -SPLandroidx/security/crypto/MasterKey$Builder;->build()Landroidx/security/crypto/MasterKey; -SPLandroidx/security/crypto/MasterKey$Builder;->setKeyScheme(Landroidx/security/crypto/MasterKey$KeyScheme;)Landroidx/security/crypto/MasterKey$Builder; -Landroidx/security/crypto/MasterKey$Builder$Api23Impl; -SPLandroidx/security/crypto/MasterKey$Builder$Api23Impl;->build(Landroidx/security/crypto/MasterKey$Builder;)Landroidx/security/crypto/MasterKey; -Landroidx/security/crypto/MasterKey$KeyScheme; -SPLandroidx/security/crypto/MasterKey$KeyScheme;->$values()[Landroidx/security/crypto/MasterKey$KeyScheme; -SPLandroidx/security/crypto/MasterKey$KeyScheme;->()V -SPLandroidx/security/crypto/MasterKey$KeyScheme;->(Ljava/lang/String;I)V -Landroidx/security/crypto/MasterKeys; -SPLandroidx/security/crypto/MasterKeys;->()V -SPLandroidx/security/crypto/MasterKeys;->createAES256GCMKeyGenParameterSpec(Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec; -SPLandroidx/security/crypto/MasterKeys;->getOrCreate(Landroid/security/keystore/KeyGenParameterSpec;)Ljava/lang/String; -SPLandroidx/security/crypto/MasterKeys;->keyExists(Ljava/lang/String;)Z -SPLandroidx/security/crypto/MasterKeys;->validate(Landroid/security/keystore/KeyGenParameterSpec;)V Landroidx/sqlite/SQLite; SPLandroidx/sqlite/SQLite;->execSQL(Landroidx/sqlite/SQLiteConnection;Ljava/lang/String;)V Landroidx/sqlite/SQLiteConnection; @@ -22796,49 +22777,16 @@ SPLcom/dot/gallery/core/Settings$Misc$rememberGridSize$1$1$value$1;->invokeSuspe Lcom/dot/gallery/core/SettingsKt; SPLcom/dot/gallery/core/SettingsKt;->()V SPLcom/dot/gallery/core/SettingsKt;->getDataStore(Landroid/content/Context;)Landroidx/datastore/core/DataStore; -Lcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader; -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->handles(Ljava/io/File;)Z -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader;->handles(Ljava/lang/Object;)Z -Lcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory; -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedFileModelLoader$Factory;->build(Lcom/bumptech/glide/load/model/MultiModelLoaderFactory;)Lcom/bumptech/glide/load/model/ModelLoader; -Lcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder; -SPLcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V -Lcom/dot/gallery/core/decoder/glide/EncryptedMediaStream; -Lcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader; -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->handles(Landroid/net/Uri;)Z -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader;->handles(Ljava/lang/Object;)Z -Lcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory; -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->(Landroid/content/Context;)V -SPLcom/dot/gallery/core/decoder/glide/EncryptedUriModelLoader$Factory;->build(Lcom/bumptech/glide/load/model/MultiModelLoaderFactory;)Lcom/bumptech/glide/load/model/ModelLoader; -Lcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder; -SPLcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;Lkotlin/jvm/functions/Function0;)V Lcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder; SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->()V SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->handles(Ljava/io/InputStream;Lcom/bumptech/glide/load/Options;)Z SPLcom/dot/gallery/core/decoder/glide/HeifBitmapDecoder;->handles(Ljava/lang/Object;Lcom/bumptech/glide/load/Options;)Z -Lcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder; -SPLcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/HeifEncryptedDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V -Lcom/dot/gallery/core/decoder/glide/IsEncryptedVaultFileKt; -SPLcom/dot/gallery/core/decoder/glide/IsEncryptedVaultFileKt;->isEncryptedVaultFile(Ljava/io/File;)Z Lcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder; SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->()V SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->handles(Ljava/io/InputStream;Lcom/bumptech/glide/load/Options;)Z SPLcom/dot/gallery/core/decoder/glide/JxlBitmapDecoder;->handles(Ljava/lang/Object;Lcom/bumptech/glide/load/Options;)Z -Lcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder; -SPLcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder;->()V -SPLcom/dot/gallery/core/decoder/glide/JxlEncryptedDecoder;->(Lcom/bumptech/glide/load/engine/bitmap_recycle/BitmapPool;)V Lcom/dot/gallery/core/presentation/components/AppBarKt; SPLcom/dot/gallery/core/presentation/components/AppBarKt;->$r8$lambda$DsPR7b3BsTxlzkrJf3l-Kymmt-Y(I)I SPLcom/dot/gallery/core/presentation/components/AppBarKt;->$r8$lambda$Vay4j8nd0PNlEP9MEQJtcWNVJKY(Ljava/util/List;Landroidx/navigation/NavController;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/State;Landroidx/compose/animation/AnimatedVisibilityScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; @@ -23530,7 +23478,6 @@ Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$15QqL12KsHwPyPiG3qbiCHi8Dhs(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MetadataDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$2QDUjMU4Hx8kYc7tO3hlvnIgsW8(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$711IgaQTzxOzDjGMNmIWCtjJyBU(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MediaDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$A6DCbF69_hYXBjdmUra8K7PT9YI(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; PLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$L85sszyn6t4C4HmsRrdgfiM6Ac0(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/ClassifierDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$ZfOKcFvP4FXN5mKwqbyGoFRl4-A(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/BlacklistDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->$r8$lambda$w69ZHrj2x99hkAuAipMHFUH2Pew(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao_Impl; @@ -23544,7 +23491,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_imageE SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_mediaDao$lambda$2(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MediaDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_metadataDao$lambda$5(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/MetadataDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_pinnedDao$lambda$0(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->_vaultDao$lambda$4(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->access$internalInitInvalidationTracker(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;Landroidx/sqlite/SQLiteConnection;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->createAutoMigrations(Ljava/util/Map;)Ljava/util/List; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->createInvalidationTracker()Landroidx/room/InvalidationTracker; @@ -23559,7 +23505,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getMeta SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getPinnedDao()Lcom/dot/gallery/feature_node/data/data_source/PinnedDao; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getRequiredAutoMigrationSpecClasses()Ljava/util/Set; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getRequiredTypeConverterClasses()Ljava/util/Map; -SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;->getVaultDao()Lcom/dot/gallery/feature_node/data/data_source/VaultDao; Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0;->(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$ExternalSyntheticLambda0;->invoke()Ljava/lang/Object; @@ -23587,12 +23532,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$$External Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1; SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1;->(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl;)V SPLcom/dot/gallery/feature_node/data/data_source/InternalDatabase_Impl$createOpenDelegate$_openDelegate$1;->onOpen(Landroidx/sqlite/SQLiteConnection;)V -Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder;->()V -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder;->(Landroid/content/Context;)V -Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion; -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion;->()V -SPLcom/dot/gallery/feature_node/data/data_source/KeychainHolder$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/dot/gallery/feature_node/data/data_source/MediaDao; PLcom/dot/gallery/feature_node/data/data_source/MediaDao;->updateMedia$suspendImpl(Lcom/dot/gallery/feature_node/data/data_source/MediaDao;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; PLcom/dot/gallery/feature_node/data/data_source/MediaDao;->updateMedia(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -23752,27 +23691,6 @@ Lcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion; SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->()V SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V SPLcom/dot/gallery/feature_node/data/data_source/PinnedDao_Impl$Companion;->getRequiredConverters()Ljava/util/List; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->()V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->(Landroidx/room/RoomDatabase;)V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl;->getVaults()Lkotlinx/coroutines/flow/Flow; -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$$ExternalSyntheticLambda4; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$$ExternalSyntheticLambda4;->(Ljava/lang/String;)V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$1; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$1;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$2; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$2;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$3; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$3;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$4; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$4;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$5; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$5;->()V -Lcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion; -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->()V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -SPLcom/dot/gallery/feature_node/data/data_source/VaultDao_Impl$Companion;->getRequiredConverters()Ljava/util/List; Lcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery; SPLcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery;->()V SPLcom/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery;->()V @@ -23820,7 +23738,6 @@ SPLcom/dot/gallery/feature_node/data/data_source/mediastore/queries/QueryFlow;-> SPLcom/dot/gallery/feature_node/data/data_source/mediastore/queries/QueryFlow;->()V Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->()V -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)V SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->access$getDatabase$p(Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getAlbumThumbnails()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getAlbums(Lcom/dot/gallery/feature_node/domain/util/MediaOrder;)Lkotlinx/coroutines/flow/Flow; @@ -23834,7 +23751,6 @@ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getPinnedA SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getSetting(Landroidx/datastore/preferences/core/Preferences$Key;Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getTimelineSettings()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getTrashed()Lkotlinx/coroutines/flow/Flow; -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->getVaults()Lkotlinx/coroutines/flow/Flow; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;->updateInternalDatabase(Lkotlin/coroutines/Continuation;)Ljava/lang/Object; Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$Companion; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$Companion;->()V @@ -23894,8 +23810,6 @@ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getSetting$$ SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getSetting$$inlined$map$1$2$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getTrashed$$inlined$map$1; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getTrashed$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;)V -Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getVaults$$inlined$map$1; -SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$getVaults$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;)V Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1; SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1;->(Lcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl;Lkotlin/coroutines/Continuation;)V SPLcom/dot/gallery/feature_node/data/repository/MediaRepositoryImpl$updateInternalDatabase$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; @@ -24071,10 +23985,6 @@ Lcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase; SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->()V SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->()V SPLcom/dot/gallery/feature_node/domain/model/UIEvent$UpdateDatabase;->equals(Ljava/lang/Object;)Z -Lcom/dot/gallery/feature_node/domain/model/VaultState; -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->()V -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->(Ljava/util/List;Z)V -SPLcom/dot/gallery/feature_node/domain/model/VaultState;->(Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; Lcom/dot/gallery/feature_node/domain/util/Converters; SPLcom/dot/gallery/feature_node/domain/util/Converters;->()V @@ -25134,7 +25044,6 @@ SPLcom/dot/gallery/feature_node/presentation/util/AppBottomSheetStateKt$$Externa Lcom/dot/gallery/feature_node/presentation/util/ContextExtKt; SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->()V SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->getMediaStoreVersion(Landroid/content/Context;)Ljava/lang/String; -SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isManageFilesAllowed()Z PLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isMediaUpToDate(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->isMetadataUpToDate(Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; PLcom/dot/gallery/feature_node/presentation/util/ContextExtKt;->launchManageMedia(Landroid/content/Context;)V @@ -25264,9 +25173,6 @@ SPLcom/dot/gallery/feature_node/presentation/util/Screen$TimelineScreen;-> Lcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen; SPLcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen;->()V SPLcom/dot/gallery/feature_node/presentation/util/Screen$TrashedScreen;->()V -Lcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen; -SPLcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen;->()V -SPLcom/dot/gallery/feature_node/presentation/util/Screen$VaultScreen;->()V Lcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt; SPLcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt;->mediaSharedElement$lambda$4$lambda$0(Landroidx/compose/runtime/MutableState;)Z SPLcom/dot/gallery/feature_node/presentation/util/SharedElementsExtKt;->mediaSharedElement(Landroidx/compose/animation/SharedTransitionScope;Landroidx/compose/ui/Modifier;ZLcom/dot/gallery/feature_node/domain/model/Media;Landroidx/compose/animation/AnimatedVisibilityScope;Landroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; @@ -25305,21 +25211,13 @@ SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;-> SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/dot/gallery/feature_node/presentation/util/StateExtKt$mapMediaToItem$2;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules$KeyModule; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules$KeyModule;->provide()Z -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_BindsModule_Binds_LazyMapKey; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_BindsModule_Binds_LazyMapKey;->()V -Lcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_KeyModule_Provide_LazyMapKey; -SPLcom/dot/gallery/feature_node/presentation/vault/VaultViewModel_HiltModules_KeyModule_Provide_LazyMapKey;->()V Lcom/dot/gallery/injection/AppModule; SPLcom/dot/gallery/injection/AppModule;->()V SPLcom/dot/gallery/injection/AppModule;->()V SPLcom/dot/gallery/injection/AppModule;->provideDatabase(Landroid/app/Application;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; SPLcom/dot/gallery/injection/AppModule;->provideEventHandler()Lcom/dot/gallery/feature_node/domain/util/EventHandler; -SPLcom/dot/gallery/injection/AppModule;->provideKeychainHolder(Landroid/content/Context;)Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; SPLcom/dot/gallery/injection/AppModule;->provideMediaDistributor(Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;Lcom/dot/gallery/feature_node/domain/util/EventHandler;)Lcom/dot/gallery/core/MediaDistributor; SPLcom/dot/gallery/injection/AppModule;->provideMediaHandler(Landroid/content/Context;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;)Lcom/dot/gallery/core/MediaHandler; -SPLcom/dot/gallery/injection/AppModule;->provideMediaRepository(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; SPLcom/dot/gallery/injection/AppModule;->provideMediaSelector()Lcom/dot/gallery/core/MediaSelector; SPLcom/dot/gallery/injection/AppModule;->provideSearchHelper(Landroid/content/Context;)Lcom/dot/gallery/feature_node/presentation/search/SearchHelper; SPLcom/dot/gallery/injection/AppModule;->provideWorkManager(Landroid/content/Context;)Landroidx/work/WorkManager; @@ -25327,14 +25225,11 @@ Lcom/dot/gallery/injection/AppModule_ProvideDatabaseFactory; SPLcom/dot/gallery/injection/AppModule_ProvideDatabaseFactory;->provideDatabase(Landroid/app/Application;)Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase; Lcom/dot/gallery/injection/AppModule_ProvideEventHandlerFactory; SPLcom/dot/gallery/injection/AppModule_ProvideEventHandlerFactory;->provideEventHandler()Lcom/dot/gallery/feature_node/domain/util/EventHandler; -Lcom/dot/gallery/injection/AppModule_ProvideKeychainHolderFactory; -SPLcom/dot/gallery/injection/AppModule_ProvideKeychainHolderFactory;->provideKeychainHolder(Landroid/content/Context;)Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder; Lcom/dot/gallery/injection/AppModule_ProvideMediaDistributorFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaDistributorFactory;->provideMediaDistributor(Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;Lcom/dot/gallery/feature_node/domain/util/EventHandler;)Lcom/dot/gallery/core/MediaDistributor; Lcom/dot/gallery/injection/AppModule_ProvideMediaHandlerFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaHandlerFactory;->provideMediaHandler(Landroid/content/Context;Lcom/dot/gallery/feature_node/domain/repository/MediaRepository;)Lcom/dot/gallery/core/MediaHandler; Lcom/dot/gallery/injection/AppModule_ProvideMediaRepositoryFactory; -SPLcom/dot/gallery/injection/AppModule_ProvideMediaRepositoryFactory;->provideMediaRepository(Landroid/content/Context;Landroidx/work/WorkManager;Lcom/dot/gallery/feature_node/data/data_source/InternalDatabase;Lcom/dot/gallery/feature_node/data/data_source/KeychainHolder;)Lcom/dot/gallery/feature_node/domain/repository/MediaRepository; Lcom/dot/gallery/injection/AppModule_ProvideMediaSelectorFactory; SPLcom/dot/gallery/injection/AppModule_ProvideMediaSelectorFactory;->provideMediaSelector()Lcom/dot/gallery/core/MediaSelector; Lcom/dot/gallery/injection/AppModule_ProvideSearchHelperFactory; @@ -29376,4 +29271,4 @@ Lokio/internal/ResourceFileSystem$$ExternalSyntheticLambda1; SPLokio/internal/ResourceFileSystem$$ExternalSyntheticLambda1;->(Lokio/internal/ResourceFileSystem;)V Lokio/internal/ResourceFileSystem$Companion; SPLokio/internal/ResourceFileSystem$Companion;->()V -SPLokio/internal/ResourceFileSystem$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V \ No newline at end of file +SPLokio/internal/ResourceFileSystem$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..6a32c6100d --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/debug/kotlin/com/dot/gallery/core/sandbox/UnknownSizeVideoProvider.kt b/app/src/debug/kotlin/com/dot/gallery/core/sandbox/UnknownSizeVideoProvider.kt new file mode 100644 index 0000000000..bc87a595e5 --- /dev/null +++ b/app/src/debug/kotlin/com/dot/gallery/core/sandbox/UnknownSizeVideoProvider.kt @@ -0,0 +1,143 @@ +package com.dot.gallery.core.sandbox + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import androidx.core.graphics.createBitmap +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException + +class UnknownSizeVideoProvider : ContentProvider() { + + override fun onCreate(): Boolean { + return true + } + + override fun getType(uri: Uri): String { + return VIDEO_MIME_TYPE + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + return openPipe( + uri = uri, + mimeType = VIDEO_MIME_TYPE, + bytes = MP4_HEADER_BYTES, + ) + } + + override fun openTypedAssetFile( + uri: Uri, + mimeTypeFilter: String, + opts: Bundle?, + signal: CancellationSignal?, + ): AssetFileDescriptor { + val descriptor = openPipe( + uri = uri, + mimeType = IMAGE_MIME_TYPE, + bytes = createThumbnailBytes(), + ) + return AssetFileDescriptor( + descriptor, + 0L, + AssetFileDescriptor.UNKNOWN_LENGTH, + ) + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + return 0 + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int { + return 0 + } + + private fun openPipe(uri: Uri, mimeType: String, bytes: ByteArray): ParcelFileDescriptor { + return openPipeHelper(uri, mimeType, null, bytes) { output, _, _, _, pipeBytes -> + try { + ParcelFileDescriptor.AutoCloseOutputStream(output).use { stream -> + stream.write(pipeBytes) + } + } catch (_: IOException) { + // The reader may close the pipe before consuming the complete fixture. + } + } + } + + private fun createThumbnailBytes(): ByteArray { + val bitmap = createBitmap( + width = THUMBNAIL_SIZE, + height = THUMBNAIL_SIZE, + config = Bitmap.Config.ARGB_8888, + ) + return try { + bitmap.eraseColor(Color.CYAN) + ByteArrayOutputStream().use { output -> + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) { + throw FileNotFoundException("Unable to create test thumbnail") + } + output.toByteArray() + } + } finally { + bitmap.recycle() + } + } + + companion object { + private const val IMAGE_MIME_TYPE = "image/png" + private const val THUMBNAIL_SIZE = 224 + private const val VIDEO_MIME_TYPE = "video/mp4" + private val MP4_HEADER_BYTES = byteArrayOf( + 0x00, + 0x00, + 0x00, + 0x18, + 0x66, + 0x74, + 0x79, + 0x70, + 0x69, + 0x73, + 0x6F, + 0x6D, + 0x00, + 0x00, + 0x00, + 0x00, + 0x69, + 0x73, + 0x6F, + 0x6D, + 0x6D, + 0x70, + 0x34, + 0x32, + ) + } +} diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..caa0f82f59 --- /dev/null +++ b/app/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E53935 + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50cf33f92b..6dbb7d0507 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,24 +14,18 @@ - - + + - - - - - - - - + + - - - - + + + @@ -62,53 +55,31 @@ + android:theme="@style/Theme.Gallery"> - + + - - - + + + - - - - + - - - + @@ -117,51 +88,27 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - @@ -197,6 +144,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:foregroundServiceType="dataSync" /> - \ No newline at end of file + diff --git a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt index eb6b3142e5..ccf8cf10cd 100644 --- a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt +++ b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt @@ -12,14 +12,16 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.dot.gallery.core.MediaDistributor -import com.dot.gallery.core.ml.ModelManager -import com.dot.gallery.core.decoder.supportHeifDecoder -import com.dot.gallery.core.decoder.supportJxlDecoder -import com.dot.gallery.core.decoder.supportVaultDecoder +import com.dot.gallery.core.decoder.supportSandboxedHeifDecoder +import com.dot.gallery.core.decoder.supportSandboxedJxlDecoder import com.dot.gallery.core.decoder.supportVideoFrame2 +import com.dot.gallery.core.ml.ModelManager +import com.dot.gallery.core.sandbox.IsolatedImageDecoder +import com.dot.gallery.core.sandbox.MediaPreviewDecoder +import com.dot.gallery.core.sandbox.SandboxedDecoderHolder import com.dot.gallery.core.workers.MetadataCollectionWorker -import com.dot.gallery.core.workers.TempVaultCleanupWorker -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis import com.github.panpf.sketch.PlatformContext import com.github.panpf.sketch.SingletonSketch import com.github.panpf.sketch.Sketch @@ -35,12 +37,12 @@ import com.github.panpf.sketch.request.supportPauseLoadWhenScrolling import com.github.panpf.sketch.resize.Precision import com.github.panpf.sketch.util.appCacheDirectory import dagger.hilt.android.HiltAndroidApp -import okio.FileSystem +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import javax.inject.Inject +import okio.FileSystem @HiltAndroidApp class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provider { @@ -53,9 +55,8 @@ class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provide supportVideoFrame2() supportAnimatedWebp() supportAnimatedHeif() - supportHeifDecoder() - supportJxlDecoder() - supportVaultDecoder() + supportSandboxedHeifDecoder() + supportSandboxedJxlDecoder() } val diskCache = DiskCache.Builder(context, FileSystem.SYSTEM) .directory(context.appCacheDirectory()) @@ -101,6 +102,15 @@ class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provide @Inject lateinit var modelManager: ModelManager + @Inject + internal lateinit var isolatedImageDecoder: IsolatedImageDecoder + + @Inject + internal lateinit var mediaPreviewDecoder: MediaPreviewDecoder + + @Inject + internal lateinit var aiMediaAnalysis: AiMediaAnalysis + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { @@ -110,6 +120,8 @@ class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provide super.onCreate() + SandboxedDecoderHolder.init(isolatedImageDecoder) + workManager.enqueueUniqueWork( uniqueWorkName = "MetadataCollection", existingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE, @@ -117,13 +129,11 @@ class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provide .build() ) - // Schedule periodic cleanup of stale decrypted temp files. - TempVaultCleanupWorker.schedule(workManager) - - // Initialize ML models (copies from assets on withML, checks presence on noML) + // Initial bundled-model availability probe; UI and workers observe this result. appScope.launch { - modelManager.initializeModels() + aiMediaAnalysis.initialize() + modelManager.refreshStatus() } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/EditBackupManager.kt b/app/src/main/kotlin/com/dot/gallery/core/EditBackupManager.kt deleted file mode 100644 index 5104208fe9..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/EditBackupManager.kt +++ /dev/null @@ -1,280 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.core - -import android.content.ContentValues -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.provider.MediaStore -import com.dot.gallery.feature_node.data.data_source.EditHistoryDao -import com.dot.gallery.feature_node.domain.model.EditedMedia -import com.dot.gallery.feature_node.presentation.util.printDebug -import com.dot.gallery.feature_node.presentation.util.printError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileInputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -class EditBackupManager( - private val context: Context, - private val editHistoryDao: EditHistoryDao -) { - - private val backupDir: File - get() = File(context.filesDir, BACKUP_DIR_NAME).also { if (!it.exists()) it.mkdirs() } - - /** - * Back up the original image bytes before an override edit. - * If a backup already exists for this media, it is preserved (first edit's original). - */ - suspend fun backupOriginal(mediaId: Long, uri: Uri, mimeType: String): Boolean = - withContext(Dispatchers.IO) { - try { - // If already backed up, keep the first original - if (editHistoryDao.hasOriginalBackup(mediaId)) { - printDebug("Backup already exists for mediaId=$mediaId, keeping original") - // Update the edit timestamp - editHistoryDao.getEditedMedia(mediaId)?.let { existing -> - editHistoryDao.upsertEditedMedia( - existing.copy(editTimestamp = System.currentTimeMillis()) - ) - } - return@withContext true - } - - val backupFile = File(backupDir, "$mediaId.bak") - val bytesCopied = streamCopyFromUri(uri, backupFile) - if (bytesCopied < 0) return@withContext false - - editHistoryDao.upsertEditedMedia( - EditedMedia( - mediaId = mediaId, - originalUri = uri, - backupPath = backupFile.absolutePath, - originalMimeType = mimeType, - editTimestamp = System.currentTimeMillis() - ) - ) - printDebug("Backed up original for mediaId=$mediaId ($bytesCopied bytes)") - true - } catch (e: Exception) { - printError("Failed to backup original for mediaId=$mediaId: ${e.message}") - false - } - } - - /** - * Restore the original image from backup, writing it back to the media URI. - * Returns the decoded original Bitmap on success, or null on failure. - */ - suspend fun restoreOriginal(mediaId: Long): Bitmap? = withContext(Dispatchers.IO) { - try { - val editedMedia = editHistoryDao.getEditedMedia(mediaId) - ?: return@withContext null - - val backupFile = File(editedMedia.backupPath) - if (!backupFile.exists()) { - printError("Backup file not found: ${editedMedia.backupPath}") - editHistoryDao.deleteEditedMedia(mediaId) - return@withContext null - } - - val bitmap = FileInputStream(backupFile).use { fis -> - BitmapFactory.decodeStream(fis) - } ?: return@withContext null - - bitmap - } catch (e: Exception) { - printError("Failed to restore original for mediaId=$mediaId: ${e.message}") - null - } - } - - /** - * Get an InputStream for the original backup file. - * Caller is responsible for closing the stream. - */ - suspend fun getOriginalStream(mediaId: Long): InputStream? = withContext(Dispatchers.IO) { - val editedMedia = editHistoryDao.getEditedMedia(mediaId) ?: return@withContext null - val backupFile = File(editedMedia.backupPath) - if (!backupFile.exists()) return@withContext null - FileInputStream(backupFile) - } - - /** - * Write original bytes back to a media URI and clean up the backup. - */ - suspend fun revertToOriginal(mediaId: Long): Boolean = withContext(Dispatchers.IO) { - try { - val editedMedia = editHistoryDao.getEditedMedia(mediaId) - ?: return@withContext false - - val backupFile = File(editedMedia.backupPath) - if (!backupFile.exists()) { - editHistoryDao.deleteEditedMedia(mediaId) - return@withContext false - } - - // Stream-copy backup back to the media URI (constant memory) - val outStream = context.contentResolver.openOutputStream(editedMedia.originalUri) - ?: throw IOException("Failed to open output stream for ${editedMedia.originalUri}") - FileInputStream(backupFile).use { fis -> - outStream.use { out -> - fis.copyTo(out, bufferSize = STREAM_BUFFER_SIZE) - } - } - - // Update IS_PENDING to 0 - context.contentResolver.update( - editedMedia.originalUri, - ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, - null - ) - - // Clean up - backupFile.delete() - editHistoryDao.deleteEditedMedia(mediaId) - printDebug("Reverted mediaId=$mediaId to original") - true - } catch (e: Exception) { - printError("Failed to revert mediaId=$mediaId: ${e.message}") - false - } - } - - /** - * Delete a backup (e.g., when the media itself is deleted). - */ - suspend fun deleteBackup(mediaId: Long) = withContext(Dispatchers.IO) { - val editedMedia = editHistoryDao.getEditedMedia(mediaId) ?: return@withContext - File(editedMedia.backupPath).delete() - editHistoryDao.deleteEditedMedia(mediaId) - } - - /** - * Check if a media item has a recoverable original. - */ - suspend fun hasOriginalBackup(mediaId: Long): Boolean = - editHistoryDao.hasOriginalBackup(mediaId) - - /** - * Flow-based check for UI reactivity. - */ - fun hasOriginalBackupFlow(mediaId: Long): Flow = - editHistoryDao.hasOriginalBackupFlow(mediaId) - - /** - * Clean up orphaned backups (where the media no longer exists). - */ - suspend fun cleanupOrphans(existingMediaIds: List) = withContext(Dispatchers.IO) { - val allEdited = editHistoryDao.getAllEditedMedia() - val orphans = allEdited.filter { it.mediaId !in existingMediaIds } - orphans.forEach { File(it.backupPath).delete() } - editHistoryDao.deleteOrphans(existingMediaIds) - } - - /** - * Data class describing a single backup entry for the UI. - */ - data class BackupInfo( - val mediaId: Long, - val originalUri: Uri, - val originalMimeType: String, - val backupPath: String, - val sizeBytes: Long, - val editTimestamp: Long - ) - - /** - * Returns total disk space used by all edit backups, in bytes. - */ - suspend fun getTotalBackupSize(): Long = withContext(Dispatchers.IO) { - val dir = backupDir - if (!dir.exists()) return@withContext 0L - dir.listFiles()?.sumOf { it.length() } ?: 0L - } - - /** - * Returns a list of all backup entries with file sizes. - */ - suspend fun getAllBackupInfo(): List = withContext(Dispatchers.IO) { - editHistoryDao.getAllEditedMedia().mapNotNull { edited -> - val file = File(edited.backupPath) - if (file.exists()) { - BackupInfo( - mediaId = edited.mediaId, - originalUri = edited.originalUri, - originalMimeType = edited.originalMimeType, - backupPath = edited.backupPath, - sizeBytes = file.length(), - editTimestamp = edited.editTimestamp - ) - } else null - } - } - - /** - * Delete backups older than the given age in milliseconds. - * Returns the number of backups deleted. - */ - suspend fun deleteBackupsOlderThan(maxAgeMs: Long): Int = withContext(Dispatchers.IO) { - val cutoff = System.currentTimeMillis() - maxAgeMs - val allEdited = editHistoryDao.getAllEditedMedia() - var count = 0 - allEdited.filter { it.editTimestamp < cutoff }.forEach { edited -> - File(edited.backupPath).delete() - editHistoryDao.deleteEditedMedia(edited.mediaId) - count++ - } - count - } - - /** - * Delete specific backups by media ID list. - */ - suspend fun deleteBackups(mediaIds: List) = withContext(Dispatchers.IO) { - mediaIds.forEach { id -> - val edited = editHistoryDao.getEditedMedia(id) ?: return@forEach - File(edited.backupPath).delete() - editHistoryDao.deleteEditedMedia(id) - } - } - - /** - * Delete all backups. - */ - suspend fun deleteAllBackups() = withContext(Dispatchers.IO) { - val allEdited = editHistoryDao.getAllEditedMedia() - allEdited.forEach { File(it.backupPath).delete() } - allEdited.forEach { editHistoryDao.deleteEditedMedia(it.mediaId) } - } - - /** - * Stream-copy content from a Uri to a File using a fixed-size buffer. - * Returns the number of bytes copied, or -1 on failure. - */ - private fun streamCopyFromUri(uri: Uri, destFile: File): Long { - val inputStream: InputStream = context.contentResolver.openInputStream(uri) - ?: return -1 - return inputStream.use { src -> - destFile.outputStream().use { dst -> - src.copyTo(dst, bufferSize = STREAM_BUFFER_SIZE) - } - } - } - - companion object { - private const val BACKUP_DIR_NAME = "edit_backups" - private const val STREAM_BUFFER_SIZE = 8192 - const val AUTO_CLEANUP_AGE_MS = 90L * 24 * 60 * 60 * 1000 // 90 days - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaDistributor.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaDistributor.kt index e1697c2a8c..69e5d63808 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaDistributor.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaDistributor.kt @@ -1,21 +1,16 @@ package com.dot.gallery.core +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.util.MediaGroupType import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.util.MediaGroupType import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -66,15 +61,6 @@ interface MediaDistributor { * Media Metadata */ val metadataFlow: Flow - val locationsMediaFlow: Flow> - val geoMediaFlow: Flow> - - /** - * Vault - */ - val vaultsMediaFlow: StateFlow - fun vaultMediaFlow(vault: Vault?): StateFlow> - /** * Collections */ @@ -83,14 +69,4 @@ interface MediaDistributor { fun collectionAlbumIdsInCollection(collectionId: Long): Flow> fun collectionMediaFlow(collectionId: Long): StateFlow> - /** - * Search - */ - val imageEmbeddingsFlow: StateFlow> - - - fun locationBasedMedia( - gpsLocationNameCity: String, - gpsLocationNameCountry: String - ): Flow> -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaDistributorImpl.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaDistributorImpl.kt index b7b90838f6..25784dfb73 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaDistributorImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaDistributorImpl.kt @@ -2,6 +2,7 @@ package com.dot.gallery.core import android.content.Context import android.media.MediaScannerConnection +import android.provider.MediaStore import androidx.compose.runtime.compositionLocalOf import androidx.work.WorkInfo import androidx.work.WorkManager @@ -9,40 +10,40 @@ import com.dot.gallery.core.Settings.Misc.DEFAULT_DATE_FORMAT import com.dot.gallery.core.Settings.Misc.EXTENDED_DATE_FORMAT import com.dot.gallery.core.Settings.Misc.WEEKLY_DATE_FORMAT import com.dot.gallery.core.presentation.components.FilterKind -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.data_source.ScannedMediaDao +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.AlbumGroup +import com.dot.gallery.feature_node.data.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.AlbumThumbnail +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.ScannedMedia +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.model.shouldIgnore +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.MediaGroupType +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.OrderType +import com.dot.gallery.feature_node.data.util.mapLocked +import com.dot.gallery.feature_node.data.util.mapPinned +import com.dot.gallery.feature_node.data.util.removeBlacklisted import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.AlbumThumbnail -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings import com.dot.gallery.feature_node.domain.model.UIEvent -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.model.shouldIgnore -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.domain.util.EventHandler -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import com.dot.gallery.feature_node.domain.util.MediaGroupType -import com.dot.gallery.feature_node.domain.util.mapLocked -import com.dot.gallery.feature_node.domain.util.mapPinned -import com.dot.gallery.feature_node.domain.util.removeBlacklisted import com.dot.gallery.feature_node.presentation.util.mapMediaToItem import com.dot.gallery.feature_node.presentation.util.mediaFlow import dagger.hilt.android.qualifiers.ApplicationContext -import android.provider.MediaStore +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -62,9 +64,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton val LocalMediaDistributor = compositionLocalOf { error("No MediaDistributor provided!!! This is likely due to a missing Hilt injection in the Composable hierarchy.") @@ -75,7 +74,8 @@ class MediaDistributorImpl @Inject constructor( @param:ApplicationContext private val context: Context, private val repository: MediaRepository, private val eventHandler: EventHandler, - workManager: WorkManager + workManager: WorkManager, + private val scannedMediaDao: ScannedMediaDao, ) : MediaDistributor { private val sharingMethod = SharingStarted.WhileSubscribed(5_000L) @@ -86,9 +86,18 @@ class MediaDistributorImpl @Inject constructor( /** * Tracks media IDs that have already been submitted for a MediaStore rescan * to avoid redundant scanning of the same files. + * Persisted to the Room database so entries are cleaned when media is deleted. + * Loaded asynchronously to avoid blocking the main thread during startup. */ private val rescanRequestedIds = ConcurrentHashMap.newKeySet() + init { + appScope.launch { + rescanRequestedIds.addAll(scannedMediaDao.getScannedIds()) + scannedMediaDao.removeStaleEntries() + } + } + /** * Pull-to-refresh */ @@ -108,9 +117,13 @@ class MediaDistributorImpl @Inject constructor( /** * Album Media Sort preference flow */ - private val albumMediaSortFlow: StateFlow = + private val albumMediaSortFlow: StateFlow = Settings.Album.getAlbumMediaSortFlow(context) - .stateIn(appScope, SharingStarted.Eagerly, Settings.Album.LastSort(OrderType.Descending, FilterKind.DATE)) + .stateIn( + scope = appScope, + started = SharingStarted.Eagerly, + initialValue = Settings.Album.LastSort(OrderType.Descending, FilterKind.DATE), + ) /** * Common @@ -363,8 +376,37 @@ class MediaDistributorImpl @Inject constructor( /** * Media */ + private val allMediaResourceFlow: SharedFlow>> = + repository.mediaFlow(-1L, null) + .shareIn( + scope = appScope, + started = sharingMethod, + replay = 1, + ) + + private val mediaMappingOptionsFlow: Flow = combine( + settingsFlow, + dateFormatsFlow, + groupSimilarMedia, + enabledGroupTypes, + ) { settings, dateFormats, shouldGroupSimilar, groupTypes -> + MediaMappingOptions( + groupByMonth = settings?.groupTimelineByMonth == true, + groupSimilarMedia = shouldGroupSimilar, + enabledGroupTypes = groupTypes, + defaultDateFormat = dateFormats.first, + extendedDateFormat = dateFormats.second, + weeklyDateFormat = dateFormats.third, + ) + }.distinctUntilChanged() + override val timelineMediaFlow: SharedFlow> = - mediaFlow(-1L, null, triggerDatabaseUpdate = true) + mediaFlow( + source = allMediaResourceFlow, + albumId = -1L, + target = null, + triggerDatabaseUpdate = true, + ) @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UNCHECKED_CAST") @@ -372,28 +414,18 @@ class MediaDistributorImpl @Inject constructor( hasPermission.flatMapLatest { granted -> if (!granted) flowOf(emptyMap()) else combine( - repository.mediaFlow(-1L, null), + allMediaResourceFlow, albumsFlow, - settingsFlow, + mediaMappingOptionsFlow, blacklistedAlbumsFlow, - dateFormatsFlow, albumMediaSortFlow, - groupSimilarMedia, - enabledGroupTypes ) { values -> val allMediaResult = values[0] as Resource> val albumState = values[1] as AlbumState - val settings = values[2] as TimelineSettings? + val options = values[2] as MediaMappingOptions @Suppress("UNCHECKED_CAST") val blacklistedAlbums = values[3] as List - @Suppress("UNCHECKED_CAST") - val dateFormats = values[4] as Triple - val albumSort = values[5] as Settings.Album.LastSort - val shouldGroupSimilar = values[6] as Boolean - @Suppress("UNCHECKED_CAST") - val groupTypes = values[7] as Set - - val (defaultDateFormat, extendedDateFormat, weeklyDateFormat) = dateFormats + val albumSort = values[4] as Settings.Album.LastSort val allMedia = allMediaResult.data ?: emptyList() val albumIds = albumState.albums.mapTo(HashSet()) { it.id } @@ -420,12 +452,12 @@ class MediaDistributorImpl @Inject constructor( data = sorter.sortMedia(filtered), error = "", albumId = albumId, - groupByMonth = settings?.groupTimelineByMonth == true, - groupSimilarMedia = shouldGroupSimilar, - enabledGroupTypes = groupTypes, - defaultDateFormat = defaultDateFormat, - extendedDateFormat = extendedDateFormat, - weeklyDateFormat = weeklyDateFormat + groupByMonth = options.groupByMonth, + groupSimilarMedia = options.groupSimilarMedia, + enabledGroupTypes = options.enabledGroupTypes, + defaultDateFormat = options.defaultDateFormat, + extendedDateFormat = options.extendedDateFormat, + weeklyDateFormat = options.weeklyDateFormat, ) } result @@ -438,89 +470,90 @@ class MediaDistributorImpl @Inject constructor( override val favoritesMediaFlow: SharedFlow> = - mediaFlow(-1L, Constants.Target.TARGET_FAVORITES) + mediaFlow( + source = repository.mediaFlow(-1L, Constants.Target.TARGET_FAVORITES), + albumId = -1L, + target = Constants.Target.TARGET_FAVORITES, + ) override val trashMediaFlow: SharedFlow> = - mediaFlow(-1L, Constants.Target.TARGET_TRASH) + mediaFlow( + source = repository.mediaFlow(-1L, Constants.Target.TARGET_TRASH), + albumId = -1L, + target = Constants.Target.TARGET_TRASH, + ) @OptIn(ExperimentalCoroutinesApi::class) @Suppress("UNCHECKED_CAST") - private fun mediaFlow(albumId: Long, target: String?, triggerDatabaseUpdate: Boolean = false) = hasPermission.flatMapLatest { granted -> - if (!granted) flowOf(MediaState( - error = "No permission to access media", - isLoading = false - )) - else combine( - repository.mediaFlow(albumId, target), - settingsFlow, - blacklistedAlbumsFlow, - lockedAlbumsFlow, - dateFormatsFlow, - albumMediaSortFlow, - groupSimilarMedia, - enabledGroupTypes - ) { values -> - val result = values[0] as Resource> - val settings = values[1] as TimelineSettings? - @Suppress("UNCHECKED_CAST") - val blacklistedAlbums = values[2] as List - @Suppress("UNCHECKED_CAST") - val lockedAlbums = values[3] as List - @Suppress("UNCHECKED_CAST") - val dateFormats = values[4] as Triple - val albumSort = values[5] as Settings.Album.LastSort - val shouldGroupSimilar = values[6] as Boolean - @Suppress("UNCHECKED_CAST") - val groupTypes = values[7] as Set - - val (defaultDateFormat, extendedDateFormat, weeklyDateFormat) = dateFormats - - if (result is Resource.Error) return@combine MediaState( - error = result.message ?: "", - isLoading = false - ) - // Use custom sort for album timelines, default sort for favorites/trash - val sorter = if (target == null && albumId > 0) { - when (albumSort.kind) { - FilterKind.DATE -> MediaOrder.Date(albumSort.orderType) - FilterKind.DATE_MODIFIED -> MediaOrder.DateModified(albumSort.orderType) - FilterKind.NAME -> MediaOrder.Label(albumSort.orderType) - } + private fun mediaFlow( + source: Flow>>, + albumId: Long, + target: String?, + triggerDatabaseUpdate: Boolean = false, + ): SharedFlow> { + return hasPermission.flatMapLatest { granted -> + if (!granted) { + flowOf( + MediaState( + error = "No permission to access media", + isLoading = false, + ), + ) } else { - MediaOrder.Default - } - val lockedAlbumIds = lockedAlbums.mapTo(HashSet()) { it.id } - val data = (result.data ?: emptyList()).toMutableList().apply { - removeAll { media -> blacklistedAlbums.any { it.shouldIgnore(media, albumId) } } - // Hide media from locked albums in the main timeline - if (albumId == -1L && target == null) { - removeAll { media -> media.albumID in lockedAlbumIds } + combine( + source, + mediaMappingOptionsFlow, + blacklistedAlbumsFlow, + lockedAlbumsFlow, + ) { values -> + val result = values[0] as Resource> + val options = values[1] as MediaMappingOptions + @Suppress("UNCHECKED_CAST") + val blacklistedAlbums = values[2] as List + @Suppress("UNCHECKED_CAST") + val lockedAlbums = values[3] as List + + if (result is Resource.Error) { + return@combine MediaState( + error = result.message ?: "", + isLoading = false, + ) + } + val lockedAlbumIds = lockedAlbums.mapTo(HashSet()) { it.id } + val data = (result.data ?: emptyList()).toMutableList().apply { + removeAll { media -> + blacklistedAlbums.any { it.shouldIgnore(media, albumId) } + } + if (albumId == -1L && target == null) { + removeAll { media -> media.albumID in lockedAlbumIds } + } + } + mapMediaToItem( + data = MediaOrder.Default.sortMedia(data), + error = result.message ?: "", + albumId = albumId, + groupByMonth = options.groupByMonth, + groupSimilarMedia = options.groupSimilarMedia, + enabledGroupTypes = options.enabledGroupTypes, + defaultDateFormat = options.defaultDateFormat, + extendedDateFormat = options.extendedDateFormat, + weeklyDateFormat = options.weeklyDateFormat, + ) } } - mapMediaToItem( - data = sorter.sortMedia(data), - error = result.message ?: "", - albumId = albumId, - groupByMonth = settings?.groupTimelineByMonth == true, - groupSimilarMedia = shouldGroupSimilar, - enabledGroupTypes = groupTypes, - defaultDateFormat = defaultDateFormat, - extendedDateFormat = extendedDateFormat, - weeklyDateFormat = weeklyDateFormat - ) - } - }.mapLatest { - if (triggerDatabaseUpdate) { - eventHandler.pushEvent(UIEvent.UpdateDatabase) - } - triggerRescanForMissingDateTaken(it.media) - it - }.shareIn( - scope = appScope, - started = sharingMethod, - replay = 1 - ) + }.mapLatest { mediaState -> + if (triggerDatabaseUpdate) { + eventHandler.pushEvent(UIEvent.UpdateDatabase) + } + triggerRescanForMissingDateTaken(mediaState.media) + mediaState + }.shareIn( + scope = appScope, + started = sharingMethod, + replay = 1, + ) + } /** * Media Metadata @@ -534,108 +567,12 @@ class MediaDistributorImpl @Inject constructor( ) { metadata, isRunning, progress -> MediaMetadataState( metadata = metadata, + metadataById = metadata.associateBy { item -> item.mediaId }.toPersistentMap(), isLoading = isRunning, isLoadingProgress = progress ) } - override fun locationBasedMedia( - gpsLocationNameCity: String, - gpsLocationNameCountry: String - ): Flow> = combine( - repository.getMetadata(), - repository.getCompleteMedia() - ) { metadata, media -> - val matchingMediaIds = metadata - .filter { - it.gpsLocationNameCity == gpsLocationNameCity && - it.gpsLocationNameCountry == gpsLocationNameCountry - } - .mapTo(HashSet()) { it.mediaId } - val filteredMedia = media.data.orEmpty().filter { - it.id in matchingMediaIds - } - return@combine mapMediaToItem( - data = filteredMedia, - error = media.message ?: "", - albumId = -1L, - defaultDateFormat = dateFormatsFlow.value.first, - extendedDateFormat = dateFormatsFlow.value.second, - weeklyDateFormat = dateFormatsFlow.value.third - ) - } - - private val locationsAndGeoMediaFlow: SharedFlow, List>> = combine( - repository.getMetadata(), - timelineMediaFlow - ) { metadata, timelineState -> - val mediaById = HashMap(timelineState.media.size) - for (m in timelineState.media) { mediaById[m.id] = m } - - val locationGroupMap = LinkedHashMap() - val geoList = ArrayList(metadata.size / 2) - - for (meta in metadata) { - val media = mediaById[meta.mediaId] ?: continue - - if (meta.gpsLocationNameCity != null && meta.gpsLocationNameCountry != null) { - val key = "${meta.gpsLocationNameCity}, ${meta.gpsLocationNameCountry}" - val existing = locationGroupMap[key] - if (existing == null || media.definedTimestamp > existing.definedTimestamp) { - locationGroupMap[key] = media - } - } - - if (meta.gpsLatitude != null && meta.gpsLongitude != null) { - geoList.add( - GeoMedia( - mediaId = meta.mediaId, - latitude = meta.gpsLatitude, - longitude = meta.gpsLongitude, - locationCity = meta.gpsLocationNameCity, - locationCountry = meta.gpsLocationNameCountry, - media = media - ) - ) - } - } - - val locations = locationGroupMap.entries - .map { (location, media) -> LocationMedia(media = media, location = location) } - .sortedBy { it.location } - - Pair(locations, geoList) - }.shareIn(appScope, sharingMethod, replay = 1) - - override val locationsMediaFlow: Flow> = - locationsAndGeoMediaFlow.map { it.first } - - override val geoMediaFlow: Flow> = - locationsAndGeoMediaFlow.map { it.second } - - /** - * Vault - */ - override val vaultsMediaFlow: StateFlow = repository.getVaults() - .map { VaultState(it.data ?: emptyList(), isLoading = false) } - .stateIn(appScope, started = sharingMethod, VaultState()) - - override fun vaultMediaFlow(vault: Vault?): StateFlow> = combine( - repository.getEncryptedMedia(vault), - settingsFlow, - dateFormatsFlow - ) { result, settings, (defaultDateFormat, extendedDateFormat, weeklyDateFormat) -> - mapMediaToItem( - data = result.data ?: emptyList(), - error = result.message ?: "", - albumId = -1L, - groupByMonth = settings?.groupTimelineByMonth == true, - defaultDateFormat = defaultDateFormat, - extendedDateFormat = extendedDateFormat, - weeklyDateFormat = weeklyDateFormat - ) - }.stateIn(appScope, sharingMethod, MediaState()) - /** * Collections */ @@ -686,17 +623,6 @@ class MediaDistributorImpl @Inject constructor( ) }.stateIn(appScope, sharingMethod, MediaState()) - /** - * Search - */ - override val imageEmbeddingsFlow: StateFlow> = - repository.getImageEmbeddings() - .stateIn( - scope = appScope, - started = prioritySharingMethod, - initialValue = emptyList() - ) - /** * Triggers a MediaStore rescan for media items that have null DATE_TAKEN. * When files are transferred between devices, MediaStore may not have @@ -705,7 +631,7 @@ class MediaDistributorImpl @Inject constructor( * to read EXIF immediately, populating DATE_TAKEN and triggering a * ContentResolver change notification that refreshes the timeline. */ - private fun triggerRescanForMissingDateTaken(media: List) { + private suspend fun triggerRescanForMissingDateTaken(media: List) { val toScan = media.filter { it.takenTimestamp == null && rescanRequestedIds.add(it.id) } if (toScan.isEmpty()) return val paths = toScan.mapNotNull { it.path.takeIf { p -> p.isNotBlank() } }.toTypedArray() @@ -713,6 +639,7 @@ class MediaDistributorImpl @Inject constructor( if (paths.isNotEmpty()) { MediaScannerConnection.scanFile(context, paths, mimeTypes, null) } + scannedMediaDao.insertAll(toScan.map { ScannedMedia(id = it.id) }) } private fun mergeSubfolderAlbums( @@ -784,4 +711,4 @@ class MediaDistributorImpl @Inject constructor( } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaHandler.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaHandler.kt index 93221e81c7..b35b5bcef3 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaHandler.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaHandler.kt @@ -4,10 +4,9 @@ import android.graphics.Bitmap import android.net.Uri import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import kotlinx.coroutines.flow.Flow +import com.dot.gallery.feature_node.data.model.Media import java.util.UUID +import kotlinx.coroutines.flow.Flow interface MediaHandler { @@ -55,15 +54,6 @@ interface MediaHandler { displayName: String ): Uri? - suspend fun overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ): Boolean - suspend fun getCategoryForMediaId(mediaId: Long): String? fun getClassifiedMediaCountAtCategory(category: String): Flow @@ -74,7 +64,6 @@ interface MediaHandler { suspend fun updateAlbumThumbnail(albumId: Long, newThumbnail: Uri) fun hasAlbumThumbnail(albumId: Long): Flow suspend fun collectMetadataFor(media: Media) - suspend fun addMedia(vault: Vault, media: T) fun rotateImage(media: T, degrees: Int): UUID -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaHandlerImpl.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaHandlerImpl.kt index 6cbd6c9624..3bd5178b69 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaHandlerImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaHandlerImpl.kt @@ -8,19 +8,15 @@ import androidx.activity.result.IntentSenderRequest import androidx.compose.runtime.compositionLocalOf import androidx.work.WorkManager import com.dot.gallery.core.Settings.Misc.getTrashEnabled -import com.dot.gallery.core.workers.VaultOperationWorker -import com.dot.gallery.core.workers.enqueueVaultOperation import com.dot.gallery.core.workers.rotateImage -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.mediaPair +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext -import javax.inject.Inject val LocalMediaHandler = compositionLocalOf { error("No MediaHandler provided!!! This is likely due to a missing Hilt injection in the Composable hierarchy.") @@ -74,14 +70,6 @@ class MediaHandlerImpl @Inject constructor( } } - override suspend fun addMedia(vault: Vault, media: T) { - workManager.enqueueVaultOperation( - operation = VaultOperationWorker.OP_ENCRYPT, - media = listOf(media.getUri()), - vault = vault - ) - } - override fun rotateImage( media: T, degrees: Int @@ -131,15 +119,6 @@ class MediaHandlerImpl @Inject constructor( displayName: String ): Uri? = repository.saveImage(bitmap, format, mimeType, relativePath, displayName) - override suspend fun overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ): Boolean = repository.overrideImage(uri, bitmap, format, mimeType, relativePath, displayName) - override suspend fun getCategoryForMediaId(mediaId: Long): String? = repository.getCategoryForMediaId(mediaId) @@ -160,4 +139,4 @@ class MediaHandlerImpl @Inject constructor( override suspend fun collectMetadataFor(media: Media) = repository.collectMetadataFor(media) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaMappingOptions.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaMappingOptions.kt new file mode 100644 index 0000000000..f5f684ee3a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaMappingOptions.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.core + +import com.dot.gallery.feature_node.data.util.MediaGroupType + +internal data class MediaMappingOptions( + val groupByMonth: Boolean, + val groupSimilarMedia: Boolean, + val enabledGroupTypes: Set, + val defaultDateFormat: String, + val extendedDateFormat: String, + val weeklyDateFormat: String, +) diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaSelector.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaSelector.kt index 29a9f55f8a..1cd312d8bf 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaSelector.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaSelector.kt @@ -1,6 +1,6 @@ package com.dot.gallery.core -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaState import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaSelectorImpl.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaSelectorImpl.kt index 519f03023b..d40625dfc9 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaSelectorImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaSelectorImpl.kt @@ -1,7 +1,7 @@ package com.dot.gallery.core import androidx.compose.runtime.compositionLocalOf -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaState import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/kotlin/com/dot/gallery/core/Settings.kt b/app/src/main/kotlin/com/dot/gallery/core/Settings.kt index 46ecdf846b..c4e96014ff 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/Settings.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/Settings.kt @@ -43,9 +43,9 @@ import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.util.SdkCompat import com.dot.gallery.core.util.rememberPreference import com.dot.gallery.core.util.rememberPreferenceSerializable +import com.dot.gallery.feature_node.data.util.OrderType import com.dot.gallery.feature_node.domain.model.SearchHistory import com.dot.gallery.feature_node.domain.model.SelectionSheetConfig -import com.dot.gallery.feature_node.domain.util.OrderType import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.printDebug @@ -241,6 +241,29 @@ object Settings { } + object Security { + const val METADATA_ISOLATION_SHARED = "shared" + const val METADATA_ISOLATION_HYBRID = "hybrid" + const val METADATA_ISOLATION_PER_FILE = "per_file" + const val DEFAULT_METADATA_ISOLATION_MODE = METADATA_ISOLATION_HYBRID + + private val METADATA_ISOLATION_MODE = stringPreferencesKey("metadata_isolation_mode") + + @Composable + fun rememberMetadataIsolationMode(): MutableState { + return rememberPreference( + key = METADATA_ISOLATION_MODE, + defaultValue = DEFAULT_METADATA_ISOLATION_MODE + ) + } + + fun getMetadataIsolationMode(context: Context): Flow { + return context.dataStore.data.map { + it[METADATA_ISOLATION_MODE] ?: DEFAULT_METADATA_ISOLATION_MODE + } + } + } + object Misc { private val USER_CHOICE_MEDIA_MANAGER = booleanPreferencesKey("use_media_manager") @@ -274,12 +297,6 @@ object Settings { fun rememberLastScreen() = rememberPreference(key = LAST_SCREEN, defaultValue = Screen.TimelineScreen()) - private val LAST_SEEN_VERSION = stringPreferencesKey("last_seen_version") - - @Composable - fun rememberLastSeenVersion() = - rememberPreference(key = LAST_SEEN_VERSION, defaultValue = "") - private val FORCED_LAST_SCREEN = booleanPreferencesKey("forced_last_screen") @Composable @@ -512,12 +529,6 @@ object Settings { fun rememberAutoHideOnVideoPlay() = rememberPreference(key = AUTO_HIDE_ON_VIDEO_PLAY, defaultValue = true) - val NO_CLASSIFICATION = booleanPreferencesKey("no_classification") - - @Composable - fun rememberNoClassification() = - rememberPreference(key = NO_CLASSIFICATION, defaultValue = false) - val DATE_HEADER_FORMAT = stringPreferencesKey("date_header_format") @Composable @@ -605,14 +616,6 @@ object Settings { defaultValue = SelectionSheetConfig() ) - const val ALIAS_REFRA = "ReFra" - const val ALIAS_GALLERY = "Gallery" - private val APP_NAME_ALIAS = stringPreferencesKey("app_name_alias") - - @Composable - fun rememberAppNameAlias() = - rememberPreference(key = APP_NAME_ALIAS, defaultValue = ALIAS_REFRA) - const val FAV_ICON_DISABLED = "disabled" const val FAV_ICON_BOTTOM_END = "bottom_end" const val FAV_ICON_BOTTOM_START = "bottom_start" @@ -653,17 +656,6 @@ object Settings { } - object Vault { - const val ENCRYPT_ASK = "ask" - const val ENCRYPT_DELETE = "delete" - const val ENCRYPT_KEEP = "keep" - - private val VAULT_ENCRYPT_BEHAVIOR = stringPreferencesKey("vault_encrypt_behavior") - - @Composable - fun rememberVaultEncryptBehavior() = - rememberPreference(key = VAULT_ENCRYPT_BEHAVIOR, defaultValue = ENCRYPT_ASK) - } } sealed class Position { diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/DecoderExt.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/DecoderExt.kt index 91a34d6e4a..da0ecc0c9d 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/DecoderExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/DecoderExt.kt @@ -2,15 +2,15 @@ package com.dot.gallery.core.decoder import android.graphics.Bitmap import com.github.panpf.sketch.asImage -import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.decode.ImageInfo import com.github.panpf.sketch.decode.internal.createScaledTransformed +import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.request.RequestContext import com.github.panpf.sketch.source.DataSource import com.github.panpf.sketch.util.Size import com.github.panpf.sketch.util.calculateScaleMultiplierWithOneSide -import okio.buffer import kotlin.math.roundToInt +import okio.buffer inline fun DataSource.getImageInfo( requestContext: RequestContext, diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedBitmapFactoryDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedBitmapFactoryDecoder.kt deleted file mode 100644 index cdfa0c8667..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedBitmapFactoryDecoder.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.dot.gallery.core.decoder - -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.sketch.ComponentRegistry -import com.github.panpf.sketch.Image -import com.github.panpf.sketch.asImage -import com.github.panpf.sketch.decode.DecodeConfig -import com.github.panpf.sketch.decode.Decoder -import com.github.panpf.sketch.decode.ImageInfo -import com.github.panpf.sketch.decode.internal.DecodeHelper -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import com.github.panpf.sketch.decode.internal.HelperDecoder -import com.github.panpf.sketch.decode.internal.supportBitmapRegionDecoder -import com.github.panpf.sketch.fetch.FetchResult -import com.github.panpf.sketch.request.ImageRequest -import com.github.panpf.sketch.request.RequestContext -import com.github.panpf.sketch.request.get -import com.github.panpf.sketch.source.DataSource -import com.github.panpf.sketch.source.FileDataSource -import com.github.panpf.sketch.util.Rect - -fun ComponentRegistry.Builder.supportVaultDecoder(): ComponentRegistry.Builder = apply { - add(EncryptedRawImageDecoder.Factory()) - add(EncryptedBitmapFactoryDecoder.Factory()) - add(EncryptedVideoFrameDecoder.Factory()) -} - -/** - * Raw image formats that need special handling for animation or quality preservation. - * These are handled by EncryptedRawImageDecoder instead of EncryptedBitmapFactoryDecoder. - */ -private val RAW_IMAGE_MIME_TYPES = listOf( - "image/gif", - "image/webp", - "image/svg+xml", - "image/bmp" -) - -open class EncryptedBitmapFactoryDecoder( - requestContext: RequestContext, - dataSource: DataSource, -) : HelperDecoder( - requestContext = requestContext, - dataSource = dataSource, - decodeHelperFactory = { EncryptedBitmapFactoryDecodeHelper(requestContext.request, dataSource) } -) { - - class Factory : Decoder.Factory { - - override val key: String = "EncryptedBitmapFactoryDecoder" - - override val sortWeight: Int = 0 - - override fun create( - requestContext: RequestContext, - fetchResult: FetchResult - ): Decoder? { - val mimeType = requestContext.request.extras?.get("realMimeType") as String? ?: return null - val dataSource = fetchResult.dataSource as? FileDataSource ?: return null - val path = dataSource.getFile().path - // Don't handle raw image formats - let EncryptedRawImageDecoder handle them for animation support - if (mimeType in RAW_IMAGE_MIME_TYPES) return null - return if (path.toString().contains(BuildConfig.APPLICATION_ID) && mimeType.startsWith("image")) - EncryptedBitmapFactoryDecoder(requestContext, dataSource) - else null - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return other != null && this::class == other::class - } - - override fun hashCode(): Int { - return this::class.hashCode() - } - - override fun toString(): String = "EncryptedBitmapFactoryDecoder" - } -} - -private class EncryptedBitmapFactoryDecodeHelper(val request: ImageRequest, private val dataSource: DataSource) : - DecodeHelper { - - private val keychainHolder = KeychainHolder(request.context) - - private var _cachedImageInfo: ImageInfo? = null - - override suspend fun getImageInfo(): ImageInfo { - return _cachedImageInfo ?: dataSource.readEncryptedImageInfo(keychainHolder, exifOrientationHelper).also { _cachedImageInfo = it } - } - - override suspend fun isSupportRegion(): Boolean { - // The result returns null, which means unknown, but future versions may support it, so it is still worth trying. - return supportBitmapRegionDecoder(getImageInfo().mimeType) != false - } - - private val exifOrientation: Int by lazy { dataSource.readEncryptedExifOrientation(keychainHolder) } - private val exifOrientationHelper by lazy { ExifOrientationHelper(exifOrientation) } - - override suspend fun decode(sampleSize: Int): Image { - val decodeConfig = DecodeConfig(request, getImageInfo().mimeType, isOpaque = false).apply { - this.sampleSize = sampleSize - } - val bitmap = dataSource.decodeEncryptedBitmap( - keychainHolder = keychainHolder, - config = decodeConfig, - exifOrientationHelper = exifOrientationHelper - ) - return bitmap.asImage() - } - - override suspend fun decodeRegion(region: Rect, sampleSize: Int): Image { - val decodeConfig = DecodeConfig(request, getImageInfo().mimeType, isOpaque = false).apply { - this.sampleSize = sampleSize - } - val bitmap = dataSource.decodeEncryptedRegionBitmap( - keychainHolder = keychainHolder, - srcRect = region, - config = decodeConfig, - imageSize = getImageInfo().size, - exifOrientationHelper = exifOrientationHelper - ) - return bitmap.asImage() - } - - override fun close() { - - } - - override fun toString(): String { - return "EncryptedBitmapFactoryDecodeHelper(uri='${request.uri}', dataSource=$dataSource)" - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedDecoderExt.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedDecoderExt.kt deleted file mode 100644 index 4ae0dd87a1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedDecoderExt.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.dot.gallery.core.decoder - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.BitmapRegionDecoder -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES -import androidx.core.net.toFile -import androidx.exifinterface.media.ExifInterface -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.sketch.decode.DecodeConfig -import com.github.panpf.sketch.decode.ImageInfo -import com.github.panpf.sketch.decode.ImageInvalidException -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import com.github.panpf.sketch.decode.internal.checkImageInfo -import com.github.panpf.sketch.decode.toBitmapOptions -import com.github.panpf.sketch.source.ContentDataSource -import com.github.panpf.sketch.source.DataSource -import com.github.panpf.sketch.source.FileDataSource -import com.github.panpf.sketch.util.Rect -import com.github.panpf.sketch.util.Size -import com.github.panpf.sketch.util.toAndroidRect -import java.io.File -import java.io.IOException - -fun T.getFile(): File { - return when (this) { - is ContentDataSource -> contentUri.toFile() - is FileDataSource -> path.toFile() - else -> throw IllegalArgumentException("Unsupported DataSource type") - } -} - -/** - * Decode encrypted bitmap using BitmapFactory - */ -@Throws(IOException::class) -fun DataSource.decodeEncryptedBitmap( - keychainHolder: KeychainHolder, - config: DecodeConfig? = null, - exifOrientationHelper: ExifOrientationHelper? = null -): Bitmap { - val options = config?.toBitmapOptions() - val encryptedFile = getFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - val bitmap = BitmapFactory.decodeByteArray( - decrypted.readBytes(), - 0, - decrypted.readBytes().size, - options.apply { this?.outMimeType = decrypted.mimeType } - ) ?: throw ImageInvalidException("decode return null at decodeEncryptedBitmap") - val exifOrientationHelper1 = - exifOrientationHelper ?: ExifOrientationHelper(readEncryptedExifOrientation(keychainHolder)) - return exifOrientationHelper1.applyToBitmap(bitmap) ?: bitmap -} - -/** - * Use EncryptedBitmapRegionDecoder to decode part of a bitmap region - */ -@Throws(IOException::class) -fun DataSource.decodeEncryptedRegionBitmap( - keychainHolder: KeychainHolder, - srcRect: Rect, - config: DecodeConfig? = null, - imageSize: Size? = null, - exifOrientationHelper: ExifOrientationHelper? = null -): Bitmap { - val encryptedFile = getFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - val regionDecoder = if (VERSION.SDK_INT >= VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(decrypted.readBytes(), 0, decrypted.readBytes().size) - } else { - @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance( - decrypted.readBytes(), - 0, - decrypted.readBytes().size, - false - ) - } - val imageSize1 = - imageSize ?: readEncryptedImageInfo(keychainHolder, exifOrientationHelper).size - val exifOrientationHelper1 = - exifOrientationHelper ?: ExifOrientationHelper( - readEncryptedExifOrientation( - keychainHolder - ) - ) - val originalRegion = exifOrientationHelper1.applyToRect( - srcRect = srcRect, - spaceSize = imageSize1, - reverse = true - ) - val bitmapOptions = config?.toBitmapOptions() - val regionBitmap = try { - regionDecoder.decodeRegion(originalRegion.toAndroidRect(), bitmapOptions) - ?: throw ImageInvalidException("decode return null at decodeEncryptedRegionBitmap") - } finally { - regionDecoder.recycle() - } - - val correctedRegionImage = - exifOrientationHelper1.applyToBitmap(regionBitmap) ?: regionBitmap - return correctedRegionImage - -} - -@Throws(IOException::class) -fun DataSource.readEncryptedExifOrientation(keychainHolder: KeychainHolder): Int { - val encryptedFile = getFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - return decrypted.readBytes().inputStream().use { - ExifInterface(it).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_UNDEFINED - ) - } -} - -@Throws(IOException::class) -fun DataSource.readEncryptedImageInfoWithIgnoreExifOrientation(keychainHolder: KeychainHolder): ImageInfo { - val boundOptions = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - val encryptedFile = getFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - try { - BitmapFactory.decodeByteArray( - decrypted.readBytes(), - 0, - decrypted.readBytes().size, - boundOptions - ) - } catch (e: Exception) { - e.printStackTrace() - throw ImageInvalidException("decode return null at readEncryptedImageInfoWithIgnoreExifOrientation") - } - - val imageSize = Size(width = boundOptions.outWidth, height = boundOptions.outHeight) - return ImageInfo(size = imageSize, mimeType = decrypted.mimeType) - .apply { checkImageInfo(this) } -} - -/** - * Read image information using BitmapFactory. Parse Exif orientation - */ -fun DataSource.readEncryptedImageInfo( - keychainHolder: KeychainHolder, - helper: ExifOrientationHelper? -): ImageInfo { - val imageInfo = readEncryptedImageInfoWithIgnoreExifOrientation(keychainHolder) - val exifOrientationHelper = if (helper != null) { - helper - } else { - val exifOrientation = - readEncryptedExifOrientationWithMimeType(keychainHolder, imageInfo.mimeType) - ExifOrientationHelper(exifOrientation) - } - val correctedImageSize = exifOrientationHelper.applyToSize(imageInfo.size) - return imageInfo.copy(size = correctedImageSize) -} - -/** - * Read the Exif orientation attribute of the image, if the mimeType is not supported, return [ExifInterface.ORIENTATION_UNDEFINED] - */ -@Throws(IOException::class) -fun DataSource.readEncryptedExifOrientationWithMimeType( - keychainHolder: KeychainHolder, - mimeType: String -): Int = - if (ExifInterface.isSupportedMimeType(mimeType)) { - readEncryptedExifOrientation(keychainHolder) - } else { - ExifInterface.ORIENTATION_UNDEFINED - } - -/** - * Decode image width, height, MIME type. Parse Exif orientation - */ -fun DataSource.readEncryptedImageInfo(keychainHolder: KeychainHolder): ImageInfo = - readEncryptedImageInfo(keychainHolder, null) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedPanoramaImageLoader.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedPanoramaImageLoader.kt deleted file mode 100644 index ca45cb2d0b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedPanoramaImageLoader.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.core.decoder - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.BitmapRegionDecoder -import android.graphics.Rect -import android.os.Build -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.libs.panoramaviewer.PanoramaImageLoader -import com.dot.gallery.libs.panoramaviewer.PanoramaLog -import java.io.File - -/** - * A [PanoramaImageLoader] that decrypts encrypted vault media before decoding. - * - * This loader decrypts the `.enc` file using [KeychainHolder] to obtain the raw - * image bytes, then creates a [BitmapRegionDecoder] from those bytes for - * progressive region loading — exactly like the default loader, but with a - * decryption step up front. - * - * The decrypted bytes are held in memory for the lifetime of the loader so that - * [loadBase] and [loadRegion] can create region decoders without re-decrypting. - * Call [close] to release the decoder and allow the byte array to be GC'd. - * - * ## Usage - * - * ```kotlin - * val loader = remember(media) { - * EncryptedPanoramaImageLoader(keychainHolder, encryptedFile) - * } - * PanoramaViewer( - * imageUri = Uri.EMPTY, - * projectionType = projectionType, - * imageLoader = loader, - * modifier = Modifier.fillMaxSize() - * ) - * ``` - * - * @param keychainHolder The keychain used to decrypt the encrypted media file. - * @param encryptedFile The `.enc` file on disk containing the encrypted image. - */ -class EncryptedPanoramaImageLoader( - private val keychainHolder: KeychainHolder, - private val encryptedFile: File -) : PanoramaImageLoader { - - private var decoder: BitmapRegionDecoder? = null - private var decryptedBytes: ByteArray? = null - - override var imageWidth: Int = 0 - private set - override var imageHeight: Int = 0 - private set - - override fun initialize(): Boolean { - PanoramaLog.d("EncryptedPanoramaImageLoader.initialize() file=${encryptedFile.name}") - - // Decrypt the file to get raw image bytes - val bytes = try { - keychainHolder.decryptVaultMedia(encryptedFile).readBytes() - } catch (e: Exception) { - PanoramaLog.e("EncryptedPanoramaImageLoader.initialize() decryption failed", e) - return false - } - decryptedBytes = bytes - - // Read dimensions - val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) - imageWidth = opts.outWidth - imageHeight = opts.outHeight - PanoramaLog.d("EncryptedPanoramaImageLoader.initialize() size=${imageWidth}x${imageHeight}") - - if (imageWidth <= 0 || imageHeight <= 0) { - PanoramaLog.e("EncryptedPanoramaImageLoader.initialize() invalid dimensions") - return false - } - - // Create region decoder - decoder = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(bytes, 0, bytes.size) - } else { - @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance(bytes, 0, bytes.size, false) - } - } catch (e: Exception) { - PanoramaLog.e("EncryptedPanoramaImageLoader.initialize() BitmapRegionDecoder failed", e) - return false - } - - PanoramaLog.d("EncryptedPanoramaImageLoader.initialize() decoder created: ${decoder != null}") - return decoder != null - } - - override fun loadBase(maxDimension: Int): Bitmap? { - val d = decoder ?: run { - // Fallback: decode full image from bytes - val bytes = decryptedBytes ?: return null - var sampleSize = 1 - while (imageWidth / sampleSize > maxDimension || imageHeight / sampleSize > maxDimension) { - sampleSize *= 2 - } - val opts = BitmapFactory.Options().apply { - inSampleSize = sampleSize - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - return try { - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) - } catch (e: Exception) { - PanoramaLog.e("EncryptedPanoramaImageLoader.loadBase() fallback failed", e) - null - } - } - - var sampleSize = 1 - while (imageWidth / sampleSize > maxDimension || imageHeight / sampleSize > maxDimension) { - sampleSize *= 2 - } - val opts = BitmapFactory.Options().apply { - inSampleSize = sampleSize - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - return try { - d.decodeRegion(Rect(0, 0, imageWidth, imageHeight), opts) - } catch (e: Exception) { - PanoramaLog.e("EncryptedPanoramaImageLoader.loadBase() decodeRegion failed", e) - null - } - } - - override fun loadRegion( - left: Int, - top: Int, - right: Int, - bottom: Int, - maxDimension: Int - ): Bitmap? { - val d = decoder ?: return null - - val clampedLeft = left.coerceIn(0, imageWidth) - val clampedTop = top.coerceIn(0, imageHeight) - val clampedRight = right.coerceIn(0, imageWidth) - val clampedBottom = bottom.coerceIn(0, imageHeight) - - if (clampedRight <= clampedLeft || clampedBottom <= clampedTop) return null - - val regionW = clampedRight - clampedLeft - val regionH = clampedBottom - clampedTop - var sampleSize = 1 - while (regionW / sampleSize > maxDimension || regionH / sampleSize > maxDimension) { - sampleSize *= 2 - } - - val opts = BitmapFactory.Options().apply { - inSampleSize = sampleSize - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - return try { - d.decodeRegion(Rect(clampedLeft, clampedTop, clampedRight, clampedBottom), opts) - } catch (e: Exception) { - PanoramaLog.e("EncryptedPanoramaImageLoader.loadRegion() failed", e) - null - } - } - - override fun close() { - PanoramaLog.d("EncryptedPanoramaImageLoader.close()") - decoder?.recycle() - decoder = null - decryptedBytes = null - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRawImageDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRawImageDecoder.kt deleted file mode 100644 index 747237fda9..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRawImageDecoder.kt +++ /dev/null @@ -1,289 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.dot.gallery.core.decoder - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.ImageDecoder -import android.graphics.Movie -import android.graphics.drawable.AnimatedImageDrawable -import android.os.Build -import androidx.annotation.RequiresApi -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.sketch.asImage -import com.github.panpf.sketch.decode.DecodeConfig -import com.github.panpf.sketch.request.ImageData -import com.github.panpf.sketch.decode.Decoder -import com.github.panpf.sketch.decode.ImageInfo -import com.github.panpf.sketch.drawable.AnimatableDrawable -import com.github.panpf.sketch.drawable.MovieDrawable -import com.github.panpf.sketch.drawable.ScaledAnimatableDrawable -import com.github.panpf.sketch.fetch.FetchResult -import com.github.panpf.sketch.request.RequestContext -import com.github.panpf.sketch.request.animatedTransformation -import com.github.panpf.sketch.request.animationEndCallback -import com.github.panpf.sketch.request.animationStartCallback -import com.github.panpf.sketch.request.get -import com.github.panpf.sketch.request.repeatCount -import com.github.panpf.sketch.source.DataSource -import com.github.panpf.sketch.source.FileDataSource -import com.github.panpf.sketch.util.animatable2CompatCallbackOf -import com.github.panpf.sketch.util.safeToSoftware -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.nio.ByteBuffer - -/** - * Decoder for encrypted raw image formats (GIF, WebP, SVG, BMP). - * - * - GIF: Always animated using MovieDrawable - * - WebP: Animated using AnimatedImageDrawable on API 28+, static fallback otherwise - * - SVG, BMP: Static bitmap decoding - * - * This ensures raw image formats in the vault display properly in full-screen MediaView, - * preserving animation for GIF and animated WebP files. - */ -class EncryptedRawImageDecoder( - private val requestContext: RequestContext, - private val dataSource: DataSource, - private val mimeType: String, -) : Decoder { - - private val keychainHolder = KeychainHolder(requestContext.request.context) - - private var _imageInfo: ImageInfo? = null - private var cachedBytes: ByteArray? = null - - override suspend fun getImageInfo(): ImageInfo { - if (_imageInfo == null) { - _imageInfo = readImageInfo() - } - return _imageInfo!! - } - - private fun getDecryptedBytes(): ByteArray { - if (cachedBytes == null) { - val encryptedFile = dataSource.getFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - cachedBytes = decrypted.readBytes() - } - return cachedBytes!! - } - - private fun readImageInfo(): ImageInfo { - val bytes = getDecryptedBytes() - return when (mimeType) { - "image/gif" -> readGifImageInfo(bytes) - "image/webp" -> readWebPImageInfo(bytes) - else -> readBitmapImageInfo(bytes) - } - } - - private fun readGifImageInfo(bytes: ByteArray): ImageInfo { - val movie = Movie.decodeByteArray(bytes, 0, bytes.size) - return if (movie != null) { - ImageInfo(movie.width(), movie.height(), mimeType) - } else { - // Fallback to BitmapFactory - readBitmapImageInfo(bytes) - } - } - - private fun readWebPImageInfo(bytes: ByteArray): ImageInfo { - return readBitmapImageInfo(bytes) - } - - private fun readBitmapImageInfo(bytes: ByteArray): ImageInfo { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) - return ImageInfo(options.outWidth, options.outHeight, mimeType) - } - - private fun isAnimatedWebP(bytes: ByteArray): Boolean { - // WebP header: RIFF....WEBP - // Check for VP8X chunk with animation flag - if (bytes.size < 20) return false - - // Check RIFF header - if (bytes[0] != 'R'.code.toByte() || - bytes[1] != 'I'.code.toByte() || - bytes[2] != 'F'.code.toByte() || - bytes[3] != 'F'.code.toByte()) return false - - // Check WEBP - if (bytes[8] != 'W'.code.toByte() || - bytes[9] != 'E'.code.toByte() || - bytes[10] != 'B'.code.toByte() || - bytes[11] != 'P'.code.toByte()) return false - - // Check VP8X chunk (extended format that can contain animation) - if (bytes[12] != 'V'.code.toByte() || - bytes[13] != 'P'.code.toByte() || - bytes[14] != '8'.code.toByte() || - bytes[15] != 'X'.code.toByte()) return false - - // Animation flag is bit 1 (0x02) of the flags byte at offset 16 - return (bytes[16].toInt() and 0x02) != 0 - } - - override suspend fun decode(): ImageData { - val bytes = getDecryptedBytes() - - return when (mimeType) { - "image/gif" -> decodeGif(bytes) - "image/webp" -> decodeWebP(bytes) - else -> decodeStaticBitmap(bytes) - } - } - - private suspend fun decodeGif(bytes: ByteArray): ImageData { - val request = requestContext.request - - val movie = Movie.decodeByteArray(bytes, 0, bytes.size) - ?: throw IllegalStateException("Failed to decode GIF movie") - - val decodeConfig = DecodeConfig(request, mimeType, isOpaque = movie.isOpaque) - val config = decodeConfig.colorType.safeToSoftware() - - val movieDrawable = MovieDrawable(movie, config).apply { - setRepeatCount(request.repeatCount ?: 0) - setAnimatedTransformation(request.animatedTransformation) - } - - val animatableDrawable = AnimatableDrawable(movieDrawable).apply { - val onStart = request.animationStartCallback - val onEnd = request.animationEndCallback - if (onStart != null || onEnd != null) { - @Suppress("OPT_IN_USAGE") - GlobalScope.launch(Dispatchers.Main) { - registerAnimationCallback(animatable2CompatCallbackOf(onStart, onEnd)) - } - } - } - - val resize = requestContext.computeResize(getImageInfo().size) - return ImageData( - image = animatableDrawable.asImage(), - imageInfo = getImageInfo(), - dataFrom = dataSource.dataFrom, - resize = resize, - transformeds = null, - extras = null, - ) - } - - private suspend fun decodeWebP(bytes: ByteArray): ImageData { - val isAnimated = isAnimatedWebP(bytes) - - return if (isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - decodeAnimatedWebP(bytes) - } else { - decodeStaticBitmap(bytes) - } - } - - @RequiresApi(Build.VERSION_CODES.P) - private suspend fun decodeAnimatedWebP(bytes: ByteArray): ImageData { - val request = requestContext.request - - val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes)) - val drawable = ImageDecoder.decodeDrawable(source) { decoder, info, _ -> - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - } - - if (drawable is AnimatedImageDrawable) { - drawable.repeatCount = request.repeatCount - ?.takeIf { it != 0 } - ?: AnimatedImageDrawable.REPEAT_INFINITE - - val scaledDrawable = ScaledAnimatableDrawable(drawable).apply { - val onStart = request.animationStartCallback - val onEnd = request.animationEndCallback - if (onStart != null || onEnd != null) { - @Suppress("OPT_IN_USAGE") - GlobalScope.launch(Dispatchers.Main) { - registerAnimationCallback(animatable2CompatCallbackOf(onStart, onEnd)) - } - } - } - - val resize = requestContext.computeResize(getImageInfo().size) - return ImageData( - image = scaledDrawable.asImage(), - imageInfo = getImageInfo(), - dataFrom = dataSource.dataFrom, - resize = resize, - transformeds = null, - extras = null, - ) - } else { - // Not animated, fall back to static decoding - return decodeStaticBitmap(bytes) - } - } - - private suspend fun decodeStaticBitmap(bytes: ByteArray): ImageData { - val request = requestContext.request - val decodeConfig = DecodeConfig(request, mimeType, isOpaque = false) - val config = decodeConfig.colorType.safeToSoftware() - - val options = BitmapFactory.Options().apply { - inPreferredConfig = config - } - - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) - ?: throw IllegalStateException("Failed to decode bitmap for $mimeType") - - val resize = requestContext.computeResize(getImageInfo().size) - return ImageData( - image = bitmap.asImage(), - imageInfo = getImageInfo(), - dataFrom = dataSource.dataFrom, - resize = resize, - transformeds = null, - extras = null, - ) - } - - class Factory : Decoder.Factory { - - override val key: String = "EncryptedRawImageDecoder" - - override val sortWeight: Int = 0 - - private val supportedMimeTypes = listOf( - "image/gif", - "image/webp", - "image/svg+xml", - "image/bmp" - ) - - override fun create( - requestContext: RequestContext, - fetchResult: FetchResult - ): Decoder? { - val mimeType = requestContext.request.extras?.get("realMimeType") as? String ?: return null - if (mimeType !in supportedMimeTypes) return null - val dataSource = fetchResult.dataSource as? FileDataSource ?: return null - val path = dataSource.getFile().path - return if (path.toString().contains(BuildConfig.APPLICATION_ID)) - EncryptedRawImageDecoder(requestContext, dataSource, mimeType) - else null - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return other != null && this::class == other::class - } - - override fun hashCode(): Int { - return this::class.hashCode() - } - - override fun toString(): String = "EncryptedRawImageDecoder" - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoder.kt deleted file mode 100644 index eccb2f5dd1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoder.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.dot.gallery.core.decoder - -import android.graphics.BitmapFactory -import android.graphics.BitmapRegionDecoder -import android.graphics.Rect -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES -import androidx.core.net.toFile -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.zoomimage.subsampling.BitmapTileImage -import com.github.panpf.zoomimage.subsampling.ContentImageSource -import com.github.panpf.zoomimage.subsampling.ImageInfo -import com.github.panpf.zoomimage.subsampling.ImageSource -import com.github.panpf.zoomimage.subsampling.RegionDecoder -import com.github.panpf.zoomimage.subsampling.SubsamplingImage -import com.github.panpf.zoomimage.subsampling.TileBitmap -import com.github.panpf.zoomimage.subsampling.internal.ExifOrientationHelper -import com.github.panpf.zoomimage.util.IntRectCompat - -class EncryptedRegionDecoder( - override val subsamplingImage: SubsamplingImage, - val imageSource: ImageSource, - private val keychainHolder: KeychainHolder, -) : RegionDecoder { - - private var bitmapRegionDecoder: BitmapRegionDecoder? = null - - private val exifOrientationHelper: ExifOrientationHelper by lazy { - val exifOrientation = imageSource.readEncryptedExifOrientation(keychainHolder) - ExifOrientationHelper(exifOrientation) - } - - override val imageInfo: ImageInfo by lazy { imageSource.readEncryptedImageInfo(keychainHolder) } - - override fun close() { - bitmapRegionDecoder?.recycle() - } - - override fun copy(): RegionDecoder { - return EncryptedRegionDecoder( - subsamplingImage = subsamplingImage, - imageSource = imageSource, - keychainHolder = keychainHolder - ) - } - - override fun decodeRegion(region: IntRectCompat, sampleSize: Int): TileBitmap { - prepare() - val options = BitmapFactory.Options().apply { - inSampleSize = sampleSize - } - val originalRegion = exifOrientationHelper - .applyToRect(region, imageInfo.size, reverse = true) - val bitmap = bitmapRegionDecoder!!.decodeRegion(originalRegion.toAndroidRect(), options) - val tileImage = BitmapTileImage(bitmap) - val correctedImage = exifOrientationHelper.applyToTileImage(tileImage) - return correctedImage.bitmap - } - - override fun prepare() { - if (bitmapRegionDecoder != null) return - - val decrypted = keychainHolder.decryptVaultMedia( - (imageSource as ContentImageSource).uri.toFile() - ) - - bitmapRegionDecoder = kotlin.runCatching { - if (VERSION.SDK_INT >= VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(decrypted.readBytes(), 0, decrypted.readBytes().size) - } else { - @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance(decrypted.readBytes(), 0, decrypted.readBytes().size, false) - } - }.apply { - if (isFailure) { - throw exceptionOrNull()!! - } - }.getOrThrow() - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - other as EncryptedRegionDecoder - if (subsamplingImage != other.subsamplingImage) return false - if (imageSource != other.imageSource) return false - return true - } - - override fun hashCode(): Int { - var result = subsamplingImage.hashCode() - result = 31 * result + imageSource.hashCode() - return result - } - - override fun toString(): String { - return "EncryptedRegionDecoder(subsamplingImage=$subsamplingImage, imageSource=$imageSource)" - } - - private fun IntRectCompat.toAndroidRect(): Rect { - return Rect(left, top, right, bottom) - } - - class Factory(private val keychainHolder: KeychainHolder) : RegionDecoder.Factory { - - override suspend fun accept(subsamplingImage: SubsamplingImage): Boolean = true - - override fun checkSupport(mimeType: String): Boolean? = when (mimeType) { - "image/jpeg", "image/png", "image/webp" -> true - "image/gif", "image/bmp", "image/svg+xml" -> false - "image/heic", "image/heif" -> true - "image/avif" -> if (VERSION.SDK_INT <= 34) false else null - else -> null - } - - override fun create( - subsamplingImage: SubsamplingImage, - imageSource: ImageSource, - ): EncryptedRegionDecoder = EncryptedRegionDecoder( - subsamplingImage = subsamplingImage, - imageSource = imageSource, - keychainHolder = keychainHolder - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return other != null && this::class == other::class - } - - override fun hashCode(): Int { - return this::class.hashCode() - } - - override fun toString(): String { - return "EncryptedRegionDecoder" - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoderExt.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoderExt.kt deleted file mode 100644 index 84202d115e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedRegionDecoderExt.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.dot.gallery.core.decoder - -import android.graphics.BitmapFactory -import androidx.core.net.toFile -import androidx.exifinterface.media.ExifInterface -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.sketch.decode.ImageInvalidException -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import com.github.panpf.sketch.util.Size -import com.github.panpf.zoomimage.subsampling.ContentImageSource -import com.github.panpf.zoomimage.subsampling.ImageInfo -import com.github.panpf.zoomimage.subsampling.ImageSource -import com.github.panpf.zoomimage.util.IntSizeCompat -import com.github.panpf.zoomimage.util.isEmpty -import java.io.IOException - -@Throws(IOException::class) -fun ImageSource.readEncryptedExifOrientation(keychainHolder: KeychainHolder): Int { - return with(this as ContentImageSource) { - val encryptedFile = uri.toFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - decrypted.readBytes().inputStream().use { - ExifInterface(it).getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_UNDEFINED - ) - } - } -} - -@Throws(IOException::class) -fun ImageSource.readEncryptedImageInfoWithIgnoreExifOrientation(keychainHolder: KeychainHolder): ImageInfo { - with(this as ContentImageSource) { - val boundOptions = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - val encryptedFile = uri.toFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - try { - BitmapFactory.decodeByteArray( - decrypted.readBytes(), - 0, - decrypted.readBytes().size, - boundOptions.apply { this.outMimeType = decrypted.mimeType } - ) - } catch (e: Exception) { - e.printStackTrace() - throw ImageInvalidException("decode return null at readEncryptedImageInfoWithIgnoreExifOrientation") - } - - val mimeType = decrypted.mimeType - val imageSize = IntSizeCompat(width = boundOptions.outWidth, height = boundOptions.outHeight) - return ImageInfo(size = imageSize, mimeType = mimeType) - .apply { checkImageInfo(this) } - } -} - -/** - * Check if the image is valid - */ -fun checkImageSize(imageSize: IntSizeCompat) { - if (imageSize.isEmpty()) { - throw ImageInvalidException("Invalid image size. size=$imageSize") - } -} - -/** - * Check if the image is valid - */ -fun checkImageInfo(imageInfo: ImageInfo) { - checkImageSize(imageInfo.size) -} - - /** - * Read image information using BitmapFactory. Parse Exif orientation - */ -fun ImageSource.readEncryptedImageInfo(keychainHolder: KeychainHolder, helper: ExifOrientationHelper?): ImageInfo { - val imageInfo = readEncryptedImageInfoWithIgnoreExifOrientation(keychainHolder) - val exifOrientationHelper = if (helper != null) { - helper - } else { - val exifOrientation = readEncryptedExifOrientationWithMimeType(keychainHolder, imageInfo.mimeType) - ExifOrientationHelper(exifOrientation) - } - val correctedImageSize = exifOrientationHelper.applyToSize(Size(width = imageInfo.size.width, height = imageInfo.size.height)) - return imageInfo.copy(size = correctedImageSize.toIntSizedCompat()) -} - fun Size.toIntSizedCompat() = IntSizeCompat(width = width, height = height) - -/** - * Read the Exif orientation attribute of the image, if the mimeType is not supported, return [ExifInterface.ORIENTATION_UNDEFINED] - */ -@Throws(IOException::class) -fun ImageSource.readEncryptedExifOrientationWithMimeType(keychainHolder: KeychainHolder, mimeType: String): Int = - if (ExifInterface.isSupportedMimeType(mimeType)) { - readEncryptedExifOrientation(keychainHolder) - } else { - ExifInterface.ORIENTATION_UNDEFINED - } - -/** - * Decode image width, height, MIME type. Parse Exif orientation - */ -fun ImageSource.readEncryptedImageInfo(keychainHolder: KeychainHolder): ImageInfo = readEncryptedImageInfo(keychainHolder,null) diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedVideoFrameDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedVideoFrameDecoder.kt deleted file mode 100644 index d5c0b598e5..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/EncryptedVideoFrameDecoder.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.dot.gallery.core.decoder - -import android.media.MediaMetadataRetriever -import android.media.MediaMetadataRetriever.BitmapParams -import androidx.exifinterface.media.ExifInterface -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.github.panpf.sketch.Image -import com.github.panpf.sketch.Sketch -import com.github.panpf.sketch.asImage -import com.github.panpf.sketch.decode.DecodeConfig -import com.github.panpf.sketch.decode.DecodeException -import com.github.panpf.sketch.decode.Decoder -import com.github.panpf.sketch.decode.ImageInfo -import com.github.panpf.sketch.decode.internal.DecodeHelper -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import com.github.panpf.sketch.decode.internal.HelperDecoder -import com.github.panpf.sketch.decode.internal.checkImageInfo -import com.github.panpf.sketch.fetch.FetchResult -import com.github.panpf.sketch.request.ImageRequest -import com.github.panpf.sketch.request.RequestContext -import com.github.panpf.sketch.request.get -import com.github.panpf.sketch.request.videoFrameMicros -import com.github.panpf.sketch.request.videoFrameOption -import com.github.panpf.sketch.request.videoFramePercent -import com.github.panpf.sketch.source.ContentDataSource -import com.github.panpf.sketch.source.DataSource -import com.github.panpf.sketch.source.FileDataSource -import com.github.panpf.sketch.source.getFileOrNull -import com.github.panpf.sketch.util.Rect -import com.github.panpf.sketch.util.Size -import com.github.panpf.sketch.util.div -import java.io.File -import java.io.FileOutputStream - -class EncryptedVideoFrameDecoder( - private val requestContext: RequestContext, - private val dataSource: DataSource, - private val mimeType: String, -) : HelperDecoder( - requestContext = requestContext, - dataSource = dataSource, - decodeHelperFactory = { - EncryptedVideoFrameDecodeHelper( - sketch = requestContext.sketch, - request = requestContext.request, - dataSource = dataSource, - mimeType = mimeType - ) - } -) { - - class Factory : Decoder.Factory { - - override val key: String = "EncryptedVideoFrameDecoder" - - override val sortWeight: Int = 0 - - override fun create( - requestContext: RequestContext, - fetchResult: FetchResult - ): EncryptedVideoFrameDecoder? { - val mimeType = requestContext.request.extras?.get("realMimeType") as String? ?: return null - val dataSource = fetchResult.dataSource as? FileDataSource ?: return null - if (mimeType.startsWith("video")) { - return EncryptedVideoFrameDecoder( - requestContext = requestContext, - dataSource = dataSource, - mimeType = mimeType - ) - } - return null - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return other != null && this::class == other::class - } - - override fun hashCode(): Int { - return this::class.hashCode() - } - - override fun toString(): String = "EncryptedVideoFrameDecoder" - } -} - -private class EncryptedVideoFrameDecodeHelper( - val sketch: Sketch, - val request: ImageRequest, - val dataSource: DataSource, - private val mimeType: String, -) : DecodeHelper { - - private var _cachedImageInfo: ImageInfo? = null - - override suspend fun getImageInfo(): ImageInfo { - return _cachedImageInfo ?: readInfo().also { _cachedImageInfo = it } - } - - override suspend fun isSupportRegion(): Boolean = false - - private val keychainHolder = KeychainHolder(request.context) - private val tempFile by lazy { - dataSource.getFileOrNull(sketch)?.toFile()?.let { createDecryptedVideoFile(it) } - } - private val mediaMetadataRetriever by lazy { - MediaMetadataRetriever().apply { - if (dataSource is ContentDataSource) { - setDataSource(request.context, dataSource.contentUri) - } else { - tempFile?.let { - setDataSource(it.path) - } ?: throw Exception("Unsupported DataSource: ${dataSource::class}") - } - } - } - private val exifOrientation: Int by lazy { readExifOrientation() } - private val exifOrientationHelper by lazy { ExifOrientationHelper(exifOrientation) } - - private fun createDecryptedVideoFile(file: File): File { - // Create a temporary file - val tempFile = File.createTempFile("${file.name}.temp", null) - val decrypted = keychainHolder.decryptVaultMedia(file) - - // Write decrypted content to the temporary file (streaming for large files) - decrypted.openStream().use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output) - } - } - - return tempFile - } - - override suspend fun decode(sampleSize: Int): Image { - val frameMicros = request.videoFrameMicros - ?: request.videoFramePercent?.let { percentDuration -> - val duration = mediaMetadataRetriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull() ?: 0L - (duration * percentDuration * 1000).toLong() - } - ?: 0L - val option = request.videoFrameOption ?: MediaMetadataRetriever.OPTION_CLOSEST_SYNC - val imageSize = getImageInfo().size - val dstSize = imageSize / sampleSize.toFloat() - val config = DecodeConfig(request, getImageInfo().mimeType, isOpaque = false) - val bitmapParams = BitmapParams().apply { - config.colorType?.also { preferredConfig = it } - } - val bitmap = mediaMetadataRetriever.getScaledFrameAtTime( - /* timeUs = */ frameMicros, - /* option = */ option, - /* dstWidth = */ dstSize.width, - /* dstHeight = */ dstSize.height, - /* params = */ bitmapParams - ) ?: throw DecodeException( - "Failed to getScaledFrameAtTime. " + - "frameMicros=$frameMicros, " + - "option=${optionToName(option)}, " + - "dstSize=$dstSize, " + - "imageSize=$imageSize, " + - "preferredConfig=${config.colorType}" - ) - val correctedBitmap = exifOrientationHelper.applyToBitmap(bitmap) ?: bitmap - return correctedBitmap.asImage() - } - - override suspend fun decodeRegion(region: Rect, sampleSize: Int): Image { - throw UnsupportedOperationException("Unsupported region decode") - } - - private fun readInfo(): ImageInfo { - val srcWidth = mediaMetadataRetriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - val srcHeight = mediaMetadataRetriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 - val imageSize = Size(width = srcWidth, height = srcHeight) - val correctedImageSize = exifOrientationHelper.applyToSize(imageSize) - return ImageInfo(size = correctedImageSize, mimeType = mimeType) - .apply { checkImageInfo(this) } - } - - private fun readExifOrientation(): Int { - val videoRotation = mediaMetadataRetriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) - ?.toIntOrNull() ?: 0 - val exifOrientation = when (videoRotation) { - 90 -> ExifInterface.ORIENTATION_ROTATE_90 - 180 -> ExifInterface.ORIENTATION_ROTATE_180 - 270 -> ExifInterface.ORIENTATION_ROTATE_270 - else -> ExifInterface.ORIENTATION_UNDEFINED - } - return exifOrientation - } - - private fun optionToName(option: Int): String { - return when (option) { - MediaMetadataRetriever.OPTION_CLOSEST -> "CLOSEST" - MediaMetadataRetriever.OPTION_CLOSEST_SYNC -> "CLOSEST_SYNC" - MediaMetadataRetriever.OPTION_NEXT_SYNC -> "NEXT_SYNC" - MediaMetadataRetriever.OPTION_PREVIOUS_SYNC -> "PREVIOUS_SYNC" - else -> "Unknown($option)" - } - } - - override fun toString(): String { - return "VideoFrameDecodeHelper(uri='${request.uri}', dataSource=$dataSource, mimeType=$mimeType)" - } - - override fun close() { - mediaMetadataRetriever.close() - tempFile?.delete() - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageFileFormat.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageFileFormat.kt new file mode 100644 index 0000000000..69a2d15917 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageFileFormat.kt @@ -0,0 +1,17 @@ +package com.dot.gallery.core.decoder + +internal enum class ImageFileFormat( + val sandboxMimeType: String? = null, +) { + ANIMATED_WEBP, + AVIF(sandboxMimeType = "image/avif"), + BMP, + GIF, + HEIF(sandboxMimeType = "image/heif"), + JPEG, + JXL(sandboxMimeType = "image/jxl"), + PNG, + SVG, + TIFF, + WEBP; +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifier.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifier.kt new file mode 100644 index 0000000000..5ba1068b54 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifier.kt @@ -0,0 +1,244 @@ +package com.dot.gallery.core.decoder + +internal const val IMAGE_HEADER_BYTES = 4 * 1024 + +internal fun classifyImageHeader(header: ByteArray, length: Int): ImageFileFormat? { + val availableBytes = length.coerceIn(minimumValue = 0, maximumValue = header.size) + return when { + isJxlCodestream(header = header, length = availableBytes) -> ImageFileFormat.JXL + isJxlContainer(header = header, length = availableBytes) -> ImageFileFormat.JXL + isJpeg(header = header, length = availableBytes) -> ImageFileFormat.JPEG + isPng(header = header, length = availableBytes) -> ImageFileFormat.PNG + isGif(header = header, length = availableBytes) -> ImageFileFormat.GIF + isAnimatedWebp(header = header, length = availableBytes) -> ImageFileFormat.ANIMATED_WEBP + isWebp(header = header, length = availableBytes) -> ImageFileFormat.WEBP + isBmp(header = header, length = availableBytes) -> ImageFileFormat.BMP + isTiff(header = header, length = availableBytes) -> ImageFileFormat.TIFF + isSvg(header = header, length = availableBytes) -> ImageFileFormat.SVG + else -> classifyIsoBaseMediaFormat(header = header, length = availableBytes) + } +} + +private fun isJxlCodestream(header: ByteArray, length: Int): Boolean { + return length >= 2 && header[0] == 0xFF.toByte() && header[1] == 0x0A.toByte() +} + +private fun isJxlContainer(header: ByteArray, length: Int): Boolean { + return matchesBytes( + header = header, + length = length, + offset = 0, + expected = byteArrayOf( + 0x00, + 0x00, + 0x00, + 0x0C, + 0x4A, + 0x58, + 0x4C, + 0x20, + 0x0D, + 0x0A, + 0x87.toByte(), + 0x0A, + ), + ) +} + +private fun isJpeg(header: ByteArray, length: Int): Boolean { + return matchesBytes( + header = header, + length = length, + offset = 0, + expected = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()), + ) +} + +private fun isPng(header: ByteArray, length: Int): Boolean { + return matchesBytes( + header = header, + length = length, + offset = 0, + expected = byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ), + ) +} + +private fun isGif(header: ByteArray, length: Int): Boolean { + return matchesAscii(header = header, length = length, offset = 0, expected = "GIF87a") || + matchesAscii(header = header, length = length, offset = 0, expected = "GIF89a") +} + +private fun isWebp(header: ByteArray, length: Int): Boolean { + return matchesAscii(header = header, length = length, offset = 0, expected = "RIFF") && + matchesAscii(header = header, length = length, offset = 8, expected = "WEBP") +} + +private fun isAnimatedWebp(header: ByteArray, length: Int): Boolean { + return isWebp(header = header, length = length) && + matchesAscii(header = header, length = length, offset = 12, expected = "VP8X") && + length > WEBP_EXTENDED_FLAGS_OFFSET && + header[WEBP_EXTENDED_FLAGS_OFFSET].toInt() and WEBP_ANIMATION_FLAG != 0 +} + +private fun isBmp(header: ByteArray, length: Int): Boolean { + return matchesAscii(header = header, length = length, offset = 0, expected = "BM") +} + +private fun isTiff(header: ByteArray, length: Int): Boolean { + val littleEndian = byteArrayOf(0x49, 0x49, 0x2A, 0x00) + val bigEndian = byteArrayOf(0x4D, 0x4D, 0x00, 0x2A) + return matchesBytes( + header = header, + length = length, + offset = 0, + expected = littleEndian, + ) || matchesBytes( + header = header, + length = length, + offset = 0, + expected = bigEndian, + ) +} + +private fun isSvg(header: ByteArray, length: Int): Boolean { + val searchLimit = minOf(length, SVG_SEARCH_BYTES) + if (searchLimit == 0) { + return false + } + + val headerText = header.decodeToString( + startIndex = 0, + endIndex = searchLimit, + throwOnInvalidSequence = false, + ) + return SVG_TAG_REGEX.containsMatchIn(input = headerText) +} + +private fun classifyIsoBaseMediaFormat(header: ByteArray, length: Int): ImageFileFormat? { + var offset = 0 + while (offset + ISO_BOX_HEADER_BYTES <= length) { + val boxSize = readUnsignedInt(header = header, offset = offset) + if (boxSize < ISO_BOX_HEADER_BYTES.toLong() || boxSize > Int.MAX_VALUE.toLong()) { + return null + } + + val boxEnd = offset + boxSize.toInt() + if (boxEnd > length) { + return null + } + + if ( + boxSize >= MINIMUM_FTYP_BOX_BYTES && + matchesAscii(header = header, length = length, offset = offset + 4, expected = "ftyp") + ) { + return classifyFtypBox( + header = header, + boxStart = offset, + boxEnd = boxEnd, + ) + } + offset = boxEnd + } + return null +} + +private fun classifyFtypBox(header: ByteArray, boxStart: Int, boxEnd: Int): ImageFileFormat? { + val brandsStart = boxStart + ISO_BOX_HEADER_BYTES + if (brandsStart + BRAND_BYTES > boxEnd) { + return null + } + + val primaryBrand = classifyBrand(header = header, offset = brandsStart) + var hasHeifBrand = primaryBrand == ImageFileFormat.HEIF + if (primaryBrand == ImageFileFormat.AVIF) { + return ImageFileFormat.AVIF + } + + var brandOffset = brandsStart + MAJOR_BRAND_AND_MINOR_VERSION_BYTES + while (brandOffset + BRAND_BYTES <= boxEnd) { + when (classifyBrand(header = header, offset = brandOffset)) { + ImageFileFormat.AVIF -> return ImageFileFormat.AVIF + ImageFileFormat.HEIF -> hasHeifBrand = true + else -> Unit + } + brandOffset += BRAND_BYTES + } + return ImageFileFormat.HEIF.takeIf { hasHeifBrand } +} + +private fun classifyBrand(header: ByteArray, offset: Int): ImageFileFormat? { + val brand = String(header, offset, BRAND_BYTES, Charsets.ISO_8859_1) + return when (brand) { + "avif", "avis" -> ImageFileFormat.AVIF + in HEIF_BRANDS -> ImageFileFormat.HEIF + else -> null + } +} + +private fun matchesAscii( + header: ByteArray, + length: Int, + offset: Int, + expected: String, +): Boolean { + return matchesBytes( + header = header, + length = length, + offset = offset, + expected = expected.toByteArray(Charsets.ISO_8859_1), + ) +} + +private fun matchesBytes( + header: ByteArray, + length: Int, + offset: Int, + expected: ByteArray, +): Boolean { + if (offset < 0 || offset + expected.size > length) { + return false + } + return expected.indices.all { index -> + header[offset + index] == expected[index] + } +} + +private fun readUnsignedInt(header: ByteArray, offset: Int): Long { + return ((header[offset].toLong() and 0xFFL) shl 24) or + ((header[offset + 1].toLong() and 0xFFL) shl 16) or + ((header[offset + 2].toLong() and 0xFFL) shl 8) or + (header[offset + 3].toLong() and 0xFFL) +} + +private const val BRAND_BYTES = 4 +private const val ISO_BOX_HEADER_BYTES = 8 +private const val MAJOR_BRAND_AND_MINOR_VERSION_BYTES = 8 +private const val MINIMUM_FTYP_BOX_BYTES = 16L +private const val WEBP_ANIMATION_FLAG = 1 shl 1 +private const val WEBP_EXTENDED_FLAGS_OFFSET = 20 +private const val SVG_SEARCH_BYTES = 1024 + +private val SVG_TAG_REGEX = Regex(pattern = ")") + +private val HEIF_BRANDS = setOf( + "heic", + "heif", + "heim", + "heis", + "heix", + "hevc", + "hevm", + "hevs", + "hevx", + "mif1", + "msf1", +) diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderReader.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderReader.kt new file mode 100644 index 0000000000..afa2ecdff5 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/ImageHeaderReader.kt @@ -0,0 +1,41 @@ +package com.dot.gallery.core.decoder + +import android.system.Os +import java.io.FileDescriptor +import java.io.IOException +import java.io.InputStream + +internal fun InputStream.readImageHeader(): ByteArray { + return readImageHeader { header, offset, length -> + read(header, offset, length) + } +} + +internal fun FileDescriptor.preadImageHeader(): ByteArray { + return readImageHeader { header, offset, length -> + Os.pread( + this, + header, + offset, + length, + offset.toLong(), + ) + } +} + +internal fun readImageHeader( + readChunk: (header: ByteArray, offset: Int, length: Int) -> Int, +): ByteArray { + val header = ByteArray(IMAGE_HEADER_BYTES) + var totalBytes = 0 + while (totalBytes < header.size) { + val remainingBytes = header.size - totalBytes + val readBytes = readChunk(header, totalBytes, remainingBytes) + when { + readBytes <= 0 -> break + readBytes > remainingBytes -> throw IOException("Header reader returned too many bytes") + else -> totalBytes += readBytes + } + } + return header.copyOf(newSize = totalBytes) +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchHeifDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchHeifDecoder.kt new file mode 100644 index 0000000000..79b10180f4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchHeifDecoder.kt @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.decoder + +import com.dot.gallery.core.sandbox.SandboxedDecoderHolder +import com.github.panpf.sketch.ComponentRegistry +import com.github.panpf.sketch.asImage +import com.github.panpf.sketch.decode.DecodeException +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.decode.ImageInfo +import com.github.panpf.sketch.decode.internal.createScaledTransformed +import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.ImageData +import com.github.panpf.sketch.request.RequestContext +import com.github.panpf.sketch.request.get +import com.github.panpf.sketch.source.DataSource +import com.github.panpf.sketch.util.Size +import okio.buffer + +fun ComponentRegistry.Builder.supportSandboxedHeifDecoder(): ComponentRegistry.Builder { + return apply { + add(SandboxedSketchHeifDecoder.Factory()) + } +} + +/** + * Sketch HEIF/AVIF decoder backed by [com.dot.gallery.core.sandbox.IsolatedDecoderService]. + */ +class SandboxedSketchHeifDecoder( + private val requestContext: RequestContext, + private val dataSource: DataSource, + private val mimeType: String, +) : Decoder { + + class Factory : Decoder.Factory { + + override val key: String + get() = "SandboxedHeifDecoder" + + override val sortWeight: Int = 0 + + override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { + val context = requestContext.sketch.context + if (!SandboxedDecoderHolder.isEnabled(context)) { + return null + } + val mimeType = requestContext.request.extras?.get("realMimeType") as String? ?: return null + return if (SketchHeifDecoder.Factory.HEIF_MIMETYPES.any { mimeType.contains(it) }) { + SandboxedSketchHeifDecoder( + requestContext = requestContext, + dataSource = fetchResult.dataSource, + mimeType = fetchResult.mimeType ?: mimeType, + ) + } else { + null + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory + } + + override fun hashCode(): Int { + return this@Factory::class.hashCode() + } + + override fun toString(): String { + return key + } + } + + override suspend fun decode(): ImageData { + val decoder = SandboxedDecoderHolder.decoder + ?: throw DecodeException("Sandboxed image decoder is not initialized") + val requestedSize = requestContext.size.takeUnless { size -> size == Size.Origin } + val decodedImage = dataSource.openSource().buffer().use { source -> + decoder.decode( + inputStream = source.inputStream(), + mimeType = mimeType, + targetWidth = requestedSize?.width ?: 0, + targetHeight = requestedSize?.height ?: 0, + ) + } ?: throw DecodeException("Failed to decode HEIF/AVIF in sandbox") + val originalSize = Size( + width = decodedImage.originalSize.width, + height = decodedImage.originalSize.height, + ) + val targetSize = Size( + width = decodedImage.bitmap.width, + height = decodedImage.bitmap.height, + ) + + val imageInfo = ImageInfo( + width = targetSize.width, + height = targetSize.height, + mimeType = mimeType, + ) + return ImageData( + image = decodedImage.bitmap.asImage(), + imageInfo = imageInfo, + dataFrom = dataSource.dataFrom, + resize = requestContext.computeResize(imageInfo.size), + transformeds = transformedFor( + originalSize = originalSize, + targetSize = targetSize, + ), + extras = null, + ) + } + + override suspend fun getImageInfo(): ImageInfo { + val decoder = SandboxedDecoderHolder.decoder + ?: throw DecodeException("Sandboxed image decoder is not initialized") + val originalSize = dataSource.openSource().buffer().use { source -> + decoder.getSize(inputStream = source.inputStream(), mimeType = mimeType) + } + ?: throw DecodeException("Failed to read HEIF/AVIF size in sandbox") + return ImageInfo( + width = originalSize.width, + height = originalSize.height, + mimeType = mimeType, + ) + } +} + +internal fun transformedFor(originalSize: Size, targetSize: Size): List? { + if (originalSize == targetSize) { + return null + } + val scale = targetSize.width.toFloat() / originalSize.width.toFloat() + return listOf(createScaledTransformed(scale)) +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchJxlDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchJxlDecoder.kt new file mode 100644 index 0000000000..0b11eacc44 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SandboxedSketchJxlDecoder.kt @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.decoder + +import com.dot.gallery.core.decoder.SketchJxlDecoder.Factory.Companion.JXL_MIMETYPE +import com.dot.gallery.core.sandbox.SandboxedDecoderHolder +import com.github.panpf.sketch.ComponentRegistry +import com.github.panpf.sketch.asImage +import com.github.panpf.sketch.decode.DecodeException +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.decode.ImageInfo +import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.ImageData +import com.github.panpf.sketch.request.RequestContext +import com.github.panpf.sketch.request.get +import com.github.panpf.sketch.source.DataSource +import com.github.panpf.sketch.util.Size +import okio.buffer + +fun ComponentRegistry.Builder.supportSandboxedJxlDecoder(): ComponentRegistry.Builder { + return apply { + add(SandboxedSketchJxlDecoder.Factory()) + } +} + +/** + * Sketch JPEG XL decoder backed by [com.dot.gallery.core.sandbox.IsolatedDecoderService]. + */ +class SandboxedSketchJxlDecoder( + private val requestContext: RequestContext, + private val dataSource: DataSource, +) : Decoder { + + class Factory : Decoder.Factory { + + override val key: String + get() = "SandboxedJxlDecoder" + + override val sortWeight: Int = 0 + + override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { + val context = requestContext.sketch.context + if (!SandboxedDecoderHolder.isEnabled(context)) { + return null + } + val mimeType = requestContext.request.extras?.get("realMimeType") as String? ?: return null + return if (mimeType.contains(JXL_MIMETYPE)) { + SandboxedSketchJxlDecoder( + requestContext = requestContext, + dataSource = fetchResult.dataSource, + ) + } else { + null + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory + } + + override fun hashCode(): Int { + return this@Factory::class.hashCode() + } + + override fun toString(): String { + return key + } + } + + override suspend fun decode(): ImageData { + val decoder = SandboxedDecoderHolder.decoder + ?: throw DecodeException("Sandboxed image decoder is not initialized") + val requestedSize = requestContext.size.takeUnless { size -> size == Size.Origin } + val decodedImage = dataSource.openSource().buffer().use { source -> + decoder.decode( + inputStream = source.inputStream(), + mimeType = JXL_MIMETYPE, + targetWidth = requestedSize?.width ?: 0, + targetHeight = requestedSize?.height ?: 0, + ) + } ?: throw DecodeException("Failed to decode JPEG XL in sandbox") + val originalSize = Size( + width = decodedImage.originalSize.width, + height = decodedImage.originalSize.height, + ) + val targetSize = Size( + width = decodedImage.bitmap.width, + height = decodedImage.bitmap.height, + ) + + val imageInfo = ImageInfo( + width = targetSize.width, + height = targetSize.height, + mimeType = JXL_MIMETYPE, + ) + return ImageData( + image = decodedImage.bitmap.asImage(), + imageInfo = imageInfo, + dataFrom = dataSource.dataFrom, + resize = requestContext.computeResize(imageInfo.size), + transformeds = transformedFor( + originalSize = originalSize, + targetSize = targetSize, + ), + extras = null, + ) + } + + override suspend fun getImageInfo(): ImageInfo { + val decoder = SandboxedDecoderHolder.decoder + ?: throw DecodeException("Sandboxed image decoder is not initialized") + val originalSize = dataSource.openSource().buffer().use { source -> + decoder.getSize(inputStream = source.inputStream(), mimeType = JXL_MIMETYPE) + } + ?: throw DecodeException("Failed to read JPEG XL size in sandbox") + return ImageInfo( + width = originalSize.width, + height = originalSize.height, + mimeType = JXL_MIMETYPE, + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt index 6fb2c513ce..88d7397131 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt @@ -1,10 +1,10 @@ package com.dot.gallery.core.decoder import com.github.panpf.sketch.ComponentRegistry -import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.decode.Decoder import com.github.panpf.sketch.decode.ImageInfo import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.request.RequestContext import com.github.panpf.sketch.request.get import com.github.panpf.sketch.source.DataSource diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt index 4da39f135d..d5fa80574d 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt @@ -3,10 +3,10 @@ package com.dot.gallery.core.decoder import com.awxkee.jxlcoder.JxlCoder import com.dot.gallery.core.decoder.SketchJxlDecoder.Factory.Companion.JXL_MIMETYPE import com.github.panpf.sketch.ComponentRegistry -import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.decode.Decoder import com.github.panpf.sketch.decode.ImageInfo import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.ImageData import com.github.panpf.sketch.request.RequestContext import com.github.panpf.sketch.request.get import com.github.panpf.sketch.source.DataSource diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/DecryptedPayload.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/DecryptedPayload.kt deleted file mode 100644 index c311bd0d7b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/DecryptedPayload.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import java.io.File - -data class DecryptedPayload( - val bytes: ByteArray, - val mimeType: String, - val width: Int? = null, - val height: Int? = null -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DecryptedPayload - - if (width != other.width) return false - if (height != other.height) return false - if (!bytes.contentEquals(other.bytes)) return false - if (mimeType != other.mimeType) return false - - return true - } - - override fun hashCode(): Int { - var result = width ?: 0 - result = 31 * result + (height ?: 0) - result = 31 * result + bytes.contentHashCode() - result = 31 * result + mimeType.hashCode() - return result - } -} - -fun isEncryptedVaultPath(file: File): Boolean = - file.path.contains(BuildConfig.APPLICATION_ID) - -/** - * Decrypt file (image or video) into bytes. Reuses existing extension: - * file.decryptKotlin() from your project (not shown here). - */ -fun decryptMediaFile(file: File, keychainHolder: KeychainHolder): DecryptedPayload { - val decrypted = keychainHolder.decryptVaultMedia(file) - return DecryptedPayload(bytes = decrypted.readBytes(), mimeType = decrypted.mimeType) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedBitmapDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedBitmapDecoder.kt deleted file mode 100644 index 7820d2d484..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedBitmapDecoder.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import androidx.exifinterface.media.ExifInterface -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.bitmap.BitmapResource -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import java.io.ByteArrayInputStream -import java.io.InputStream - -/** - * Decodes generic images from decrypted InputStream (JPEG/PNG/etc). - * Applies orientation if EXIF present in encrypted bytes. - */ -class EncryptedBitmapDecoder( - private val bitmapPool: BitmapPool -) : ResourceDecoder { - - override fun handles(source: InputStream, options: Options): Boolean { - // Fallback decoder; let specialized (HEIF/JXL) claim first. - return true - } - - override fun decode( - source: InputStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val bytes = source.readBytes() - // Orientation extraction - val orientation = runCatching { - ExifInterface(ByteArrayInputStream(bytes)) - .getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_UNDEFINED - ) - }.getOrDefault(ExifInterface.ORIENTATION_UNDEFINED) - - val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - val helper = ExifOrientationHelper(orientation) - val oriented = helper.applyToBitmap(bmp) ?: bmp - return BitmapResource.obtain(oriented, bitmapPool) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedFileModelLoader.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedFileModelLoader.kt deleted file mode 100644 index 34567f094e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedFileModelLoader.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.content.Context -import android.net.Uri -import com.bumptech.glide.Priority -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.data.DataFetcher -import com.bumptech.glide.load.model.ModelLoader -import com.bumptech.glide.load.model.ModelLoaderFactory -import com.bumptech.glide.load.model.MultiModelLoaderFactory -import java.io.File -import java.security.MessageDigest - -/** - * Converts File or Uri models that point to encrypted vault items into an EncryptedMediaStream. - * We register both File and Uri variants to intercept whichever form the UI supplies. - */ -class EncryptedFileModelLoader( - private val context: Context -) : ModelLoader { - - override fun handles(model: File): Boolean = isEncryptedVaultFile(model) - - override fun buildLoadData( - model: File, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? { - if (!handles(model)) return null - return ModelLoader.LoadData( - EncryptedVaultKey(model.path), - EncryptedVaultFetcher(model, context) - ) - } - - class Factory( - private val context: Context - ) : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = - EncryptedFileModelLoader(context.applicationContext) - - override fun teardown() = Unit - } -} - -class EncryptedUriModelLoader( - private val context: Context -) : ModelLoader { - - override fun handles(model: Uri): Boolean { - val file = if (model.scheme == "file") model.path?.let { File(it) } else null - return file?.let { isEncryptedVaultFile(it) } == true - } - - override fun buildLoadData( - model: Uri, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? { - val file = model.path?.let { File(it) } ?: return null - if (!handles(model)) return null - return ModelLoader.LoadData( - EncryptedVaultKey(file.path), - EncryptedVaultFetcher(file, context) - ) - } - - class Factory( - private val context: Context - ) : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = - EncryptedUriModelLoader(context.applicationContext) - - override fun teardown() = Unit - } -} - -/* Key + Fetcher */ - -data class EncryptedVaultKey(private val path: String) : com.bumptech.glide.load.Key { - override fun updateDiskCacheKey(messageDigest: MessageDigest) { - messageDigest.update(path.toByteArray(Charsets.UTF_8)) - } -} - -private class EncryptedVaultFetcher( - private val file: File, - private val context: Context -) : DataFetcher { - - private var stream: EncryptedMediaStream? = null - private var cancelled = false - - override fun loadData( - priority: Priority, - callback: DataFetcher.DataCallback - ) { - if (cancelled) return - try { - val result = decryptVaultFile(file, context) - stream = result - callback.onDataReady(result) - } catch (e: Exception) { - callback.onLoadFailed(e) - } - } - - override fun cleanup() { - stream = null - } - - override fun cancel() { - cancelled = true - } - - override fun getDataClass(): Class = EncryptedMediaStream::class.java - override fun getDataSource(): DataSource = DataSource.LOCAL -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder.kt deleted file mode 100644 index 8cd9296b7e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGenericImageDecoder.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import androidx.exifinterface.media.ExifInterface -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.bitmap.BitmapResource -import com.github.panpf.sketch.decode.internal.ExifOrientationHelper -import java.io.ByteArrayInputStream - -/** - * Fallback decoder for decrypted images that are not handled by Heif/Jxl decoders. - */ -class EncryptedGenericImageDecoder( - private val bitmapPool: BitmapPool -) : ResourceDecoder { - - override fun handles(source: EncryptedMediaStream, options: Options): Boolean { - if (source.isVideo) return false - val mime = source.mimeType.lowercase() - return mime.startsWith("image/") // Let more specific decoders run first - } - - override fun decode( - source: EncryptedMediaStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val bytes = source.bytes - // First pass bounds decode for sampling. - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - val targetW = if (width > 0) width else bounds.outWidth - val targetH = if (height > 0) height else bounds.outHeight - val inSample = computeInSampleSize(bounds.outWidth, bounds.outHeight, targetW, targetH) - val decodeOpts = BitmapFactory.Options().apply { - inSampleSize = inSample - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - // Only instantiate ExifInterface if size large enough to plausibly contain orientation, - // avoiding noisy ExifInterface logs about missing thumbnails on very small images. - val orientation = if (bytes.size > 512) { - runCatching { - ExifInterface(ByteArrayInputStream(bytes)) - .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) - }.getOrDefault(ExifInterface.ORIENTATION_UNDEFINED) - } else ExifInterface.ORIENTATION_UNDEFINED - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) ?: return null - val oriented = ExifOrientationHelper(orientation).applyToBitmap(bitmap) ?: bitmap - return BitmapResource.obtain(oriented, bitmapPool) - } - - private fun computeInSampleSize(srcW: Int, srcH: Int, reqW: Int, reqH: Int): Int { - var inSample = 1 - if (srcH > reqH || srcW > reqW) { - var halfH = srcH / 2 - var halfW = srcW / 2 - while (halfH / inSample >= reqH && halfW / inSample >= reqW) { - inSample *= 2 - } - } - return inSample.coerceAtLeast(1) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGifDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGifDecoder.kt deleted file mode 100644 index 9d636eb0d7..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedGifDecoder.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.content.Context -import android.graphics.Bitmap -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.Transformation -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.gif.GifDrawable -import com.bumptech.glide.load.resource.gif.GifDrawableResource -import com.bumptech.glide.load.resource.gif.GifBitmapProvider -import com.bumptech.glide.load.resource.UnitTransformation -import com.bumptech.glide.gifdecoder.GifDecoder -import com.bumptech.glide.gifdecoder.GifHeaderParser -import com.bumptech.glide.gifdecoder.StandardGifDecoder -import java.nio.ByteBuffer - -/** - * Decoder for encrypted GIF images that produces animated GifDrawable. - * This ensures GIFs in the vault animate properly instead of showing static frames. - */ -class EncryptedGifDecoder( - private val context: Context, - private val bitmapPool: BitmapPool, - private val arrayPool: ArrayPool -) : ResourceDecoder { - - override fun handles(source: EncryptedMediaStream, options: Options): Boolean { - if (source.isVideo) return false - val mime = source.mimeType.lowercase() - return mime == "image/gif" - } - - override fun decode( - source: EncryptedMediaStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val bytes = source.bytes - val byteBuffer = ByteBuffer.wrap(bytes) - - // Parse GIF header - val parser = GifHeaderParser() - parser.setData(byteBuffer) - val header = parser.parseHeader() - - if (header.numFrames <= 0 || header.status != GifDecoder.STATUS_OK) { - return null - } - - // Compute sample size for better performance on large GIFs - val sampleSize = getSampleSize(header.width, header.height, width, height) - - // Create GIF decoder with proper provider - val bitmapProvider = GifBitmapProvider(bitmapPool, arrayPool) - val gifDecoder = StandardGifDecoder(bitmapProvider, header, byteBuffer, sampleSize) - gifDecoder.setDefaultBitmapConfig(Bitmap.Config.ARGB_8888) - gifDecoder.advance() - - val firstFrame = gifDecoder.nextFrame ?: return null - - // Use UnitTransformation (no transformation) - val unitTransformation: Transformation = UnitTransformation.get() - - val targetWidth = if (width > 0) width else header.width / sampleSize - val targetHeight = if (height > 0) height else header.height / sampleSize - - val gifDrawable = GifDrawable( - context, - gifDecoder, - unitTransformation, - targetWidth, - targetHeight, - firstFrame - ) - - return GifDrawableResource(gifDrawable) - } - - private fun getSampleSize(srcWidth: Int, srcHeight: Int, targetWidth: Int, targetHeight: Int): Int { - if (targetWidth <= 0 || targetHeight <= 0) return 1 - - val exactWidthScale = srcWidth.toFloat() / targetWidth - val exactHeightScale = srcHeight.toFloat() / targetHeight - val exactScale = minOf(exactWidthScale, exactHeightScale) - - // Round down to nearest power of 2 - val powerOfTwo = Integer.highestOneBit(exactScale.toInt().coerceAtLeast(1)) - return powerOfTwo.coerceIn(1, 4) // Limit sample size to reasonable range - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaSource.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaSource.kt deleted file mode 100644 index e8c6337aea..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaSource.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.content.Context -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.util.concurrent.atomic.AtomicReference - -/** - * Streaming representation of an encrypted media file. - * Provides a lambda to open a fresh decrypted InputStream on demand (for Glide rewinds or retries). - */ -data class EncryptedMediaSource( - val file: File, - val mimeType: String, - val isVideo: Boolean, - val sizeBytes: Long, - private val smallBytes: ByteArray?, - /** Public so streaming decoders can reuse without re-spilling. May be null if content is small. */ - val tempFile: File?, - private val decryptOnceRef: AtomicReference, - private val contextRef: Context -) { - /** Returns an InputStream over decrypted content (bytes array or temp file). */ - fun openStream(): InputStream { - smallBytes?.let { return it.inputStream() } - tempFile?.let { return it.inputStream() } - // Fallback: decrypt on demand (should rarely happen if created correctly) - val result = try { - val ep = dagger.hilt.android.EntryPointAccessors.fromApplication( - contextRef.applicationContext, - com.dot.gallery.core.decryption.DecryptManagerEntryPoint::class.java - ) - ep.decryptManager().decrypt(file) - } catch (_: Throwable) { - val keychainHolder = KeychainHolder(contextRef) - val d = keychainHolder.decryptVaultMedia(file) - com.dot.gallery.core.decryption.DecryptResult(d.readBytes(), d.mimeType) - } - return result.bytes.inputStream() - } - - /** Materialize as EncryptedMediaStream (byte array) for decoders that still require bytes. */ - fun asMediaStream(): EncryptedMediaStream { - val bytes = smallBytes ?: tempFile?.readBytes() ?: run { - val result = try { - val ep = dagger.hilt.android.EntryPointAccessors.fromApplication( - contextRef.applicationContext, - com.dot.gallery.core.decryption.DecryptManagerEntryPoint::class.java - ) - ep.decryptManager().decrypt(file) - } catch (_: Throwable) { - val keychainHolder = KeychainHolder(contextRef) - val d = keychainHolder.decryptVaultMedia(file) - com.dot.gallery.core.decryption.DecryptResult(d.readBytes(), d.mimeType) - } - result.bytes - } - return EncryptedMediaStream(bytes, mimeType, isVideo) - } -} - -private const val FALLBACK_SMALL_DECRYPT_THRESHOLD = 2 * 1024 * 1024 // 2MB fallback if adaptive not available - -internal fun createEncryptedMediaSource(context: Context, file: File): EncryptedMediaSource { - // Obtain decrypt manager via Hilt entry point if available, else fallback to direct decrypt. - val decryptResult = try { - val ep = dagger.hilt.android.EntryPointAccessors.fromApplication( - context.applicationContext, - com.dot.gallery.core.decryption.DecryptManagerEntryPoint::class.java - ) - ep.decryptManager().decrypt(file) - } catch (t: Throwable) { - val keychainHolder = KeychainHolder(context) - val d = keychainHolder.decryptVaultMedia(file) - com.dot.gallery.core.decryption.DecryptResult(d.readBytes(), d.mimeType) - } - val mime = decryptResult.mimeType - val isVideo = mime.startsWith("video") - val bytes = decryptResult.bytes - val size = bytes.size.toLong() - // Extract lightweight metadata (width/height and duration for video) and write to sidecar cache (best-effort) - runCatching { - val ep = dagger.hilt.android.EntryPointAccessors.fromApplication( - context.applicationContext, - com.dot.gallery.core.decryption.DecryptManagerEntryPoint::class.java - ) - val sidecar = ep.sidecar() - val metrics = runCatching { - dagger.hilt.android.EntryPointAccessors.fromApplication( - context.applicationContext, - com.dot.gallery.core.metrics.MetricsCollectorEntryPoint::class.java - ).metrics() - }.getOrNull() - val existing = sidecar.read(sidecar.keyForFile(file)) - if (existing == null) { - var width: Int? = null - var height: Int? = null - var duration: Long? = null - if (isVideo) { - android.media.MediaMetadataRetriever().apply { - try { - // Need a file: if large we'll spill soon anyway; for now create a temp or reuse below. - val tmpForMeta = if (size <= FALLBACK_SMALL_DECRYPT_THRESHOLD) { - val tmp = File.createTempFile("vault_meta_vid_", ".tmp", context.cacheDir) - FileOutputStream(tmp).use { it.write(bytes) } - tmp - } else null // For large we will create tmp below; defer reading after spill. - val path = tmpForMeta?.absolutePath - if (path != null) setDataSource(path) - duration = extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() - width = extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() - height = extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() - } catch (_: Throwable) { } - finally { try { release() } catch (_: Throwable) {} } - } - } else { - // Image: parse dimensions via BitmapFactory decode bounds to avoid full decode - val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) - if (opts.outWidth > 0 && opts.outHeight > 0) { - width = opts.outWidth - height = opts.outHeight - } - } - sidecar.write( - com.dot.gallery.core.decryption.MediaMetadataCacheEntry( - path = file.path, - mimeType = mime, - width = width, - height = height, - durationMs = duration - ) - ) - metrics?.incSidecarWrite() - } else { - metrics?.incSidecarRead() - } - } - val adaptiveThreshold = runCatching { - val ep = dagger.hilt.android.EntryPointAccessors.fromApplication( - context.applicationContext, - com.dot.gallery.core.memory.AdaptiveDecryptConfigEntryPoint::class.java - ) - ep.adaptiveConfig().threshold() - }.getOrElse { FALLBACK_SMALL_DECRYPT_THRESHOLD } - val (smallArray, tempFile) = if (size <= adaptiveThreshold) { - bytes to null - } else { - val tmp = File.createTempFile("vault_stream_", ".tmp", context.cacheDir) - FileOutputStream(tmp).use { it.write(bytes) } - null to tmp - } - return EncryptedMediaSource( - file = file, - mimeType = mime, - isVideo = isVideo, - sizeBytes = size, - smallBytes = smallArray, - tempFile = tempFile, - decryptOnceRef = AtomicReference(true), - contextRef = context.applicationContext - ) -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaStream.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaStream.kt deleted file mode 100644 index 32e66702f8..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedMediaStream.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -/** - * In-memory representation of a decrypted media asset. - * bytes -> decrypted bytes (image frames or full video file content if you choose memory path for short clips) - * mimeType -> original (real) MIME type - * isVideo -> true if original media is a video - * - * For large videos you should decrypt to a temp file instead of holding full bytes. - * In that case adapt this class to carry a tempFile reference and only load a frame. - */ -data class EncryptedMediaStream( - val bytes: ByteArray, - val mimeType: String, - val isVideo: Boolean -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptedMediaStream - - if (isVideo != other.isVideo) return false - if (!bytes.contentEquals(other.bytes)) return false - if (mimeType != other.mimeType) return false - - return true - } - - override fun hashCode(): Int { - var result = isVideo.hashCode() - result = 31 * result + bytes.contentHashCode() - result = 31 * result + mimeType.hashCode() - return result - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedSourceBridgeDecoders.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedSourceBridgeDecoders.kt deleted file mode 100644 index a03c561d54..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedSourceBridgeDecoders.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool - -/** - * Bridges EncryptedMediaSource to existing EncryptedMediaStream based decoders for HEIF/JXL while migration proceeds. - */ -class HeifEncryptedSourceDecoder(private val pool: BitmapPool) : ResourceDecoder { - private val delegate = HeifEncryptedDecoder(pool) - override fun handles(source: EncryptedMediaSource, options: Options): Boolean = - !source.isVideo && HeifMime.isHeifMime(source.mimeType.lowercase()) - override fun decode(source: EncryptedMediaSource, width: Int, height: Int, options: Options): Resource? = - delegate.decode(source.asMediaStream(), width, height, options) -} - -class JxlEncryptedSourceDecoder(private val pool: BitmapPool) : ResourceDecoder { - private val delegate = JxlEncryptedDecoder(pool) - override fun handles(source: EncryptedMediaSource, options: Options): Boolean = - !source.isVideo && source.mimeType.equals("image/jxl", true) - override fun decode(source: EncryptedMediaSource, width: Int, height: Int, options: Options): Resource? = - delegate.decode(source.asMediaStream(), width, height, options) -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedStreamingModelLoaders.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedStreamingModelLoaders.kt deleted file mode 100644 index 7385dd916c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedStreamingModelLoaders.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.content.Context -import android.net.Uri -import com.bumptech.glide.Priority -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.data.DataFetcher -import com.bumptech.glide.load.model.ModelLoader -import com.bumptech.glide.load.model.ModelLoaderFactory -import com.bumptech.glide.load.model.MultiModelLoaderFactory -import java.io.File -import java.io.InputStream -import java.security.MessageDigest - -/** - * ModelLoader producing a streaming EncryptedMediaSource. This coexists with legacy byte-array path - * until all decoders migrate. - */ -class EncryptedStreamingFileLoader( - private val context: Context -) : ModelLoader { - - override fun handles(model: File): Boolean = isEncryptedVaultFile(model) - - override fun buildLoadData( - model: File, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? { - if (!handles(model)) return null - return ModelLoader.LoadData( - StreamVaultKey(model.path), - EncryptedSourceFetcher(model, context) - ) - } - - class Factory(private val context: Context) : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = - EncryptedStreamingFileLoader(context.applicationContext) - - override fun teardown() = Unit - } -} - -class EncryptedStreamingUriLoader( - private val context: Context -) : ModelLoader { - override fun handles(model: Uri): Boolean { - val file = if (model.scheme == "file") model.path?.let { File(it) } else null - return file?.let { isEncryptedVaultFile(it) } == true - } - - override fun buildLoadData( - model: Uri, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? { - val file = model.path?.let { File(it) } ?: return null - if (!handles(model)) return null - return ModelLoader.LoadData( - StreamVaultKey(file.path), - EncryptedSourceFetcher(file, context) - ) - } - - class Factory(private val context: Context) : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = - EncryptedStreamingUriLoader(context.applicationContext) - override fun teardown() = Unit - } -} - -/* Disk cache key */ -data class StreamVaultKey(private val path: String) : com.bumptech.glide.load.Key { - override fun updateDiskCacheKey(messageDigest: MessageDigest) { - messageDigest.update(path.toByteArray(Charsets.UTF_8)) - } -} - -/* Fetcher returning EncryptedMediaSource */ -private class EncryptedSourceFetcher( - private val file: File, - private val context: Context -) : DataFetcher { - private var data: EncryptedMediaSource? = null - private var cancelled = false - - override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { - if (cancelled) return - try { - val src = createEncryptedMediaSource(context, file) - data = src - callback.onDataReady(src) - } catch (e: Exception) { - callback.onLoadFailed(e) - } - } - - override fun cleanup() { data = null } - override fun cancel() { cancelled = true } - override fun getDataClass(): Class = EncryptedMediaSource::class.java - override fun getDataSource(): DataSource = DataSource.LOCAL -} - -/** - * Adapter loader converting EncryptedMediaSource -> InputStream so Glide's normal decoders can run. - */ -class EncryptedSourceToStreamLoader : ModelLoader { - override fun handles(model: EncryptedMediaSource): Boolean = !model.isVideo - - override fun buildLoadData( - model: EncryptedMediaSource, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? = ModelLoader.LoadData( - object : com.bumptech.glide.load.Key { // ephemeral key includes path & target size - override fun updateDiskCacheKey(messageDigest: MessageDigest) { - messageDigest.update((model.file.path + "#" + width + "x" + height).toByteArray()) - } - }, - object : DataFetcher { - private var stream: InputStream? = null - private var cancelled = false - override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { - if (cancelled) return - try { - stream = model.openStream() - callback.onDataReady(stream) - } catch (e: Exception) { - callback.onLoadFailed(e) - } - } - override fun cleanup() { stream?.close(); stream = null } - override fun cancel() { cancelled = true } - override fun getDataClass(): Class = InputStream::class.java - override fun getDataSource(): DataSource = DataSource.LOCAL - } - ) - - class Factory : ModelLoaderFactory { - override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = EncryptedSourceToStreamLoader() - override fun teardown() = Unit - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder.kt deleted file mode 100644 index 10ce1fc0b1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/EncryptedVideoFrameDecoder.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import android.media.MediaMetadataRetriever -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.bitmap.BitmapResource -import java.io.File -import java.io.FileOutputStream - -/** - * Extracts a frame from decrypted video bytes. - * For large videos consider streaming decryption to a temp file (already what we do here). - */ -class EncryptedVideoFrameDecoder( - private val bitmapPool: BitmapPool, - private val tempDirProvider: () -> File, -) : ResourceDecoder { - - override fun handles(source: EncryptedMediaStream, options: Options): Boolean = - source.isVideo - - override fun decode( - source: EncryptedMediaStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val tmp = File.createTempFile("vault_vid_", ".tmp", tempDirProvider()) - try { - FileOutputStream(tmp).use { it.write(source.bytes) } - val retriever = MediaMetadataRetriever() - val bmp = try { - retriever.setDataSource(tmp.absolutePath) - // Could add percent/time options. Defaults to first key frame. - retriever.frameAtTime - } finally { - retriever.release() - } ?: return null - return BitmapResource.obtain(bmp, bitmapPool) - } finally { - tmp.delete() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaAnimatedImageDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaAnimatedImageDecoder.kt new file mode 100644 index 0000000000..2cc2f5daef --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaAnimatedImageDecoder.kt @@ -0,0 +1,28 @@ +package com.dot.gallery.core.decoder.glide + +import android.graphics.drawable.Drawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.dot.gallery.core.decoder.ImageFileFormat +import java.io.InputStream + +internal class GalleryMediaAnimatedImageDecoder( + private val delegate: ResourceDecoder, +) : ResourceDecoder { + + override fun handles(source: GalleryMediaData, options: Options): Boolean { + return source is PlatformImageData && + source.format == ImageFileFormat.ANIMATED_WEBP + } + + override fun decode( + source: GalleryMediaData, + width: Int, + height: Int, + options: Options, + ): Resource? { + val imageData = source as? PlatformImageData ?: return null + return delegate.decode(imageData.inputStream, width, height, options) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaBitmapDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaBitmapDecoder.kt new file mode 100644 index 0000000000..78421a9ce9 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaBitmapDecoder.kt @@ -0,0 +1,50 @@ +package com.dot.gallery.core.decoder.glide + +import android.graphics.Bitmap +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import java.io.InputStream + +internal class GalleryMediaBitmapDecoder( + private val platformImageDecoder: ResourceDecoder, + private val sandboxedImageDecoder: SandboxedGalleryImageDecoder, + private val videoDecoder: VerifiedVideoDecoder, +) : ResourceDecoder { + override fun handles(source: GalleryMediaData, options: Options): Boolean { + return when (source) { + is PlatformImageData -> { + platformImageDecoder.handles(source.inputStream, options) + } + + is SandboxedImageData, + is VerifiedVideoData -> true + } + } + + override fun decode( + source: GalleryMediaData, + width: Int, + height: Int, + options: Options, + ): Resource? { + return when (source) { + is PlatformImageData -> { + platformImageDecoder.decode( + source.inputStream, + width, + height, + options, + ) + } + + is SandboxedImageData -> { + sandboxedImageDecoder.decode(source, width, height, options) + } + + is VerifiedVideoData -> { + videoDecoder.decode(source, width, height, options) + } + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaData.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaData.kt new file mode 100644 index 0000000000..1c6ed538a1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaData.kt @@ -0,0 +1,3 @@ +package com.dot.gallery.core.decoder.glide + +internal sealed interface GalleryMediaData : AutoCloseable diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaGifDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaGifDecoder.kt new file mode 100644 index 0000000000..d0fb87ea39 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaGifDecoder.kt @@ -0,0 +1,29 @@ +package com.dot.gallery.core.decoder.glide + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.dot.gallery.core.decoder.ImageFileFormat +import java.io.InputStream + +internal class GalleryMediaGifDecoder( + private val delegate: ResourceDecoder, +) : ResourceDecoder { + + override fun handles(source: GalleryMediaData, options: Options): Boolean { + return source is PlatformImageData && + source.format == ImageFileFormat.GIF && + delegate.handles(source.inputStream, options) + } + + override fun decode( + source: GalleryMediaData, + width: Int, + height: Int, + options: Options, + ): Resource? { + val imageData = source as? PlatformImageData ?: return null + return delegate.decode(imageData.inputStream, width, height, options) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModel.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModel.kt new file mode 100644 index 0000000000..899e734b2b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModel.kt @@ -0,0 +1,27 @@ +package com.dot.gallery.core.decoder.glide + +import android.net.Uri +import java.util.Locale + +internal data class GalleryMediaModel( + val uri: Uri, + val declaredMimeType: String?, +) + +internal fun galleryMediaModel(uri: Uri, mimeType: String?): Any { + return when { + uri == Uri.EMPTY -> uri + else -> GalleryMediaModel( + uri = uri, + declaredMimeType = normalizeMimeType(mimeType = mimeType), + ) + } +} + +internal fun normalizeMimeType(mimeType: String?): String? { + return mimeType + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.ROOT) + ?.takeIf { normalizedMimeType -> normalizedMimeType.isNotEmpty() } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoader.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoader.kt new file mode 100644 index 0000000000..503d04205c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoader.kt @@ -0,0 +1,169 @@ +package com.dot.gallery.core.decoder.glide + +import android.content.Context +import android.os.ParcelFileDescriptor +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import com.bumptech.glide.signature.ObjectKey +import com.dot.gallery.core.decoder.IMAGE_HEADER_BYTES +import com.dot.gallery.core.decoder.ImageFileFormat +import com.dot.gallery.core.decoder.classifyImageHeader +import com.dot.gallery.core.decoder.preadImageHeader +import com.dot.gallery.core.decoder.readImageHeader +import java.io.BufferedInputStream +import java.io.IOException +import java.io.InputStream + +internal class GalleryMediaModelLoader( + private val mediaSource: GalleryMediaSource, +) : ModelLoader { + + override fun handles(model: GalleryMediaModel): Boolean { + return true + } + + override fun buildLoadData( + model: GalleryMediaModel, + width: Int, + height: Int, + options: Options, + ): ModelLoader.LoadData { + return ModelLoader.LoadData( + ObjectKey(model), + GalleryMediaFetcher( + mediaSource = mediaSource, + model = model, + ), + ) + } + + internal class Factory(private val context: Context) : + ModelLoaderFactory { + override fun build( + multiFactory: MultiModelLoaderFactory, + ): ModelLoader { + return GalleryMediaModelLoader( + mediaSource = ContentResolverGalleryMediaSource( + contentResolver = context.contentResolver, + ), + ) + } + + override fun teardown() { + } + } +} + +private class GalleryMediaFetcher( + private val mediaSource: GalleryMediaSource, + private val model: GalleryMediaModel, +) : DataFetcher { + private var mediaData: GalleryMediaData? = null + + override fun loadData( + priority: Priority, + callback: DataFetcher.DataCallback, + ) { + val loadedData = try { + val mimeType = model.declaredMimeType ?: normalizeMimeType( + mimeType = mediaSource.getMimeType(uri = model.uri), + ) + when { + mimeType?.startsWith("video/") == true -> openDeclaredVideo() + else -> openImageStream() + } + } catch (failure: Exception) { + callback.onLoadFailed(failure) + return + } + + mediaData = loadedData + callback.onDataReady(loadedData) + } + + private fun openImageStream(): GalleryMediaData { + val source = mediaSource.openInputStream(uri = model.uri) + ?: throw IOException("Unable to open media stream") + val inputStream = BufferedInputStream(source, IMAGE_HEADER_BYTES) + return try { + inputStream.mark(IMAGE_HEADER_BYTES) + val header = inputStream.readImageHeader() + inputStream.reset() + createImageData( + inputStream = inputStream, + header = header, + ) + } catch (failure: Exception) { + inputStream.close() + throw failure + } + } + + private fun openDeclaredVideo(): GalleryMediaData { + val descriptor = mediaSource.openFileDescriptor(uri = model.uri) + ?: throw IOException("Unable to open media descriptor") + return try { + val header = descriptor.fileDescriptor.preadImageHeader() + val imageFormat = classifyImageHeader(header = header, length = header.size) + when (imageFormat) { + null -> VerifiedVideoData(descriptor = descriptor) + else -> createImageData( + inputStream = ParcelFileDescriptor.AutoCloseInputStream(descriptor), + format = imageFormat, + ) + } + } catch (failure: Exception) { + descriptor.close() + throw failure + } + } + + private fun createImageData( + inputStream: BufferedInputStream, + header: ByteArray, + ): GalleryMediaData { + val format = classifyImageHeader(header = header, length = header.size) + ?: throw IOException("Image format is not verified") + return createImageData(inputStream = inputStream, format = format) + } + + private fun createImageData( + inputStream: InputStream, + format: ImageFileFormat, + ): GalleryMediaData { + val sandboxMimeType = format.sandboxMimeType + return when { + sandboxMimeType != null -> SandboxedImageData( + inputStream = inputStream, + mimeType = sandboxMimeType, + ) + + else -> PlatformImageData( + inputStream = inputStream, + format = format, + ) + } + } + + override fun cleanup() { + mediaData?.close() + mediaData = null + } + + override fun cancel() { + cleanup() + } + + override fun getDataClass(): Class { + return GalleryMediaData::class.java + } + + override fun getDataSource(): DataSource { + return DataSource.LOCAL + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSource.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSource.kt new file mode 100644 index 0000000000..8451630caf --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSource.kt @@ -0,0 +1,30 @@ +package com.dot.gallery.core.decoder.glide + +import android.content.ContentResolver +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.InputStream + +internal interface GalleryMediaSource { + fun getMimeType(uri: Uri): String? + + fun openInputStream(uri: Uri): InputStream? + + fun openFileDescriptor(uri: Uri): ParcelFileDescriptor? +} + +internal class ContentResolverGalleryMediaSource( + private val contentResolver: ContentResolver, +) : GalleryMediaSource { + override fun getMimeType(uri: Uri): String? { + return contentResolver.getType(uri) + } + + override fun openInputStream(uri: Uri): InputStream? { + return contentResolver.openInputStream(uri) + } + + override fun openFileDescriptor(uri: Uri): ParcelFileDescriptor? { + return contentResolver.openFileDescriptor(uri, "r") + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSubsamplingImageGenerator.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSubsamplingImageGenerator.kt new file mode 100644 index 0000000000..04b8217ca6 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaSubsamplingImageGenerator.kt @@ -0,0 +1,111 @@ +package com.dot.gallery.core.decoder.glide + +import android.content.Context +import android.graphics.drawable.Drawable +import com.bumptech.glide.Glide +import com.dot.gallery.core.decoder.IMAGE_HEADER_BYTES +import com.dot.gallery.core.decoder.ImageFileFormat +import com.dot.gallery.core.decoder.classifyImageHeader +import com.dot.gallery.core.decoder.readImageHeader +import com.github.panpf.zoomimage.glide.GlideSubsamplingImageGenerator +import com.github.panpf.zoomimage.subsampling.ImageSource +import com.github.panpf.zoomimage.subsampling.SubsamplingImage +import com.github.panpf.zoomimage.subsampling.SubsamplingImageGenerateResult +import java.io.BufferedInputStream +import java.io.IOException +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.Source +import okio.source + +internal fun galleryMediaSubsamplingImageGenerators(): ImmutableList { + return persistentListOf(GalleryMediaSubsamplingImageGenerator()) +} + +internal class GalleryMediaSubsamplingImageGenerator : GlideSubsamplingImageGenerator { + override suspend fun generateImage( + context: Context, + glide: Glide, + model: Any, + drawable: Drawable, + ): SubsamplingImageGenerateResult? { + if (model !is GalleryMediaModel) { + return null + } + + val imageSource = VerifiedGalleryMediaImageSource( + mediaSource = ContentResolverGalleryMediaSource( + contentResolver = context.contentResolver, + ), + model = model, + ) + return withContext(context = Dispatchers.IO) { + try { + imageSource.verify() + SubsamplingImageGenerateResult.Success( + subsamplingImage = SubsamplingImage(imageSource = imageSource), + ) + } catch (failure: Exception) { + SubsamplingImageGenerateResult.Error( + message = failure.message ?: "Image is not safe for subsampling", + ) + } + } + } + + override fun equals(other: Any?): Boolean { + return other != null && this::class == other::class + } + + override fun hashCode(): Int { + return this::class.hashCode() + } + + override fun toString(): String { + return "GalleryMediaSubsamplingImageGenerator" + } +} + +internal class VerifiedGalleryMediaImageSource( + private val mediaSource: GalleryMediaSource, + private val model: GalleryMediaModel, +) : ImageSource { + override val key: String = "verified-gallery-media:${model}" + + fun verify() { + openVerifiedStream().use { } + } + + override fun openSource(): Source { + return openVerifiedStream().source() + } + + private fun openVerifiedStream(): BufferedInputStream { + val source = mediaSource.openInputStream(uri = model.uri) + ?: throw IOException("Unable to open image for subsampling") + val inputStream = BufferedInputStream(source, IMAGE_HEADER_BYTES) + return try { + inputStream.mark(IMAGE_HEADER_BYTES) + val header = inputStream.readImageHeader() + val format = classifyImageHeader(header = header, length = header.size) + if (format !in SUBSAMPLING_IMAGE_FORMATS) { + throw IOException("Image format is not safe for subsampling") + } + inputStream.reset() + inputStream + } catch (failure: Exception) { + inputStream.close() + throw failure + } + } + + companion object { + private val SUBSAMPLING_IMAGE_FORMATS = setOf( + ImageFileFormat.JPEG, + ImageFileFormat.PNG, + ImageFileFormat.WEBP, + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifBitmapDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifBitmapDecoder.kt index f7a929c3f6..209c613cef 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifBitmapDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifBitmapDecoder.kt @@ -11,7 +11,7 @@ import com.radzivon.bartoshyk.avif.coder.HeifCoder import java.io.InputStream /** - * Decodes HEIF/AVIF from an InputStream (already decrypted if using EncryptedFileModelLoader). + * Decodes HEIF/AVIF from an InputStream. * Relies on HeifCoder (same as SketchHeifDecoder). */ class HeifBitmapDecoder( @@ -51,4 +51,4 @@ class HeifBitmapDecoder( Log.d("HeifBitmapDecoder", "decode() size=${size?.width}x${size?.height} -> ${bmp.width}x${bmp.height}") return BitmapResource.obtain(bmp, bitmapPool) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifEncryptedDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifEncryptedDecoder.kt deleted file mode 100644 index 1a7218733b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/HeifEncryptedDecoder.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool - -class HeifEncryptedDecoder( - private val bitmapPool: BitmapPool -) : ResourceDecoder { - - private val core = HeifDecoderCore(bitmapPool, "HeifEncryptedDecoder") - - override fun handles(source: EncryptedMediaStream, options: Options): Boolean { - if (source.isVideo) return false - return HeifMime.isHeifMime(source.mimeType.lowercase()) - } - - override fun decode( - source: EncryptedMediaStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val result = core.decodeBytes(source.bytes, width, height, source.mimeType) - return result.resource - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/JxlEncryptedDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/JxlEncryptedDecoder.kt deleted file mode 100644 index b0f8ef198b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/JxlEncryptedDecoder.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import com.awxkee.jxlcoder.JxlCoder -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.bitmap.BitmapResource - -class JxlEncryptedDecoder( - private val bitmapPool: BitmapPool -) : ResourceDecoder { - - override fun handles(source: EncryptedMediaStream, options: Options): Boolean { - return !source.isVideo && source.mimeType.equals("image/jxl", ignoreCase = true) - } - - override fun decode( - source: EncryptedMediaStream, - width: Int, - height: Int, - options: Options - ): Resource? { - val bytes = source.bytes - val size = JxlCoder.getSize(bytes) - val tw = if (width > 0) width else size!!.width - val th = if (height > 0) height else size!!.height - val bmp = JxlCoder.decodeSampled(bytes, tw, th) - return BitmapResource.obtain(bmp, bitmapPool) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/PlatformImageData.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/PlatformImageData.kt new file mode 100644 index 0000000000..0c71d0e6f5 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/PlatformImageData.kt @@ -0,0 +1,13 @@ +package com.dot.gallery.core.decoder.glide + +import com.dot.gallery.core.decoder.ImageFileFormat +import java.io.InputStream + +internal data class PlatformImageData( + val inputStream: InputStream, + val format: ImageFileFormat, +) : GalleryMediaData { + override fun close() { + inputStream.close() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedGalleryImageDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedGalleryImageDecoder.kt new file mode 100644 index 0000000000..6711921607 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedGalleryImageDecoder.kt @@ -0,0 +1,36 @@ +package com.dot.gallery.core.decoder.glide + +import android.graphics.Bitmap +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapResource +import com.dot.gallery.core.sandbox.SandboxedDecoderHolder +import kotlinx.coroutines.runBlocking + +internal class SandboxedGalleryImageDecoder( + private val bitmapPool: BitmapPool, +) : ResourceDecoder { + override fun handles(source: SandboxedImageData, options: Options): Boolean { + return true + } + + override fun decode( + source: SandboxedImageData, + width: Int, + height: Int, + options: Options, + ): Resource? { + val decoder = SandboxedDecoderHolder.decoder ?: return null + val result = runBlocking { + decoder.decode( + inputStream = source.inputStream, + mimeType = source.mimeType, + targetWidth = width, + targetHeight = height, + ) + } ?: return null + return BitmapResource.obtain(result.bitmap, bitmapPool) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedImageData.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedImageData.kt new file mode 100644 index 0000000000..cba8325ed1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/SandboxedImageData.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.core.decoder.glide + +import java.io.InputStream + +internal data class SandboxedImageData( + val inputStream: InputStream, + val mimeType: String, +) : GalleryMediaData { + override fun close() { + inputStream.close() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/StreamingEncryptedVideoFrameDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/StreamingEncryptedVideoFrameDecoder.kt deleted file mode 100644 index 5ac6babf85..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/StreamingEncryptedVideoFrameDecoder.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.graphics.Bitmap -import android.content.Context -import android.media.MediaMetadataRetriever -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.Option -import com.bumptech.glide.load.ResourceDecoder -import com.bumptech.glide.load.engine.Resource -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.bitmap.BitmapResource -import com.bumptech.glide.util.pool.GlideTrace -import java.io.File -import java.io.FileOutputStream - -/** - * Streaming video frame decoder that works on [EncryptedMediaSource]. - * If the source already backed large content by a temp file we reuse it; otherwise - * for small in-memory decrypted bytes we spill to a temp file just for the retriever lifecycle. - */ -class StreamingEncryptedVideoFrameDecoder( - private val bitmapPool: BitmapPool, - private val appContext: Context, - private val tempDirProvider: () -> File, -) : ResourceDecoder { - - companion object { - // Explicit frame time in microseconds - val FRAME_TIME_US: Option = Option.memory("vault.video.frameTimeUs", -1L) - // Percentage (0f..1f) of video duration - val FRAME_PERCENT: Option = Option.memory("vault.video.framePercent", -1f) - // Retrieval option for MediaMetadataRetriever; defaults to OPTION_CLOSEST_SYNC - val FRAME_OPTION: Option = Option.memory("vault.video.frameOption", MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } - - override fun handles(source: EncryptedMediaSource, options: Options): Boolean = source.isVideo - - override fun decode( - source: EncryptedMediaSource, - width: Int, - height: Int, - options: Options - ): Resource? { - GlideTrace.beginSection("StreamingEncryptedVideoFrameDecoder.decode") - try { - val backingFile: File = source.tempFile ?: run { - val tmp = File.createTempFile("vault_vid_stream_", ".tmp", tempDirProvider()) - source.openStream().use { input -> - FileOutputStream(tmp).use { out -> - val pool = runCatching { - dagger.hilt.android.EntryPointAccessors.fromApplication( - appContext, - com.dot.gallery.core.memory.ByteArrayPoolEntryPoint::class.java - ).pool() - }.getOrNull() - val buf = pool?.borrow(32 * 1024) ?: ByteArray(32 * 1024) - try { - while (true) { - val r = input.read(buf) - if (r <= 0) break - out.write(buf, 0, r) - } - out.flush() - } finally { pool?.recycle(buf) } - } - } - tmp - } - val retriever = MediaMetadataRetriever() - val bmp = try { - retriever.setDataSource(backingFile.absolutePath) - val frameTimeUs = options.get(FRAME_TIME_US).takeIf { it != null && it >= 0 } ?: run { - val percent = options.get(FRAME_PERCENT).takeIf { it != null && it in 0f..1f } - if (percent != null) { - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull()?.let { (it * 1000L * percent).toLong() } - } else null - } - val frameOpt = options.get(FRAME_OPTION) ?: MediaMetadataRetriever.OPTION_CLOSEST_SYNC - if (frameTimeUs != null) { - retriever.getFrameAtTime(frameTimeUs, frameOpt) - } else { - retriever.frameAtTime - } - } finally { - retriever.release() - } ?: return null - // Only delete if we created the temp file ourselves (when source.tempFile was null) - val resource = BitmapResource.obtain(bmp, bitmapPool) - if (source.tempFile == null) backingFile.delete() - return resource - } finally { - GlideTrace.endSection() - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoData.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoData.kt new file mode 100644 index 0000000000..ebb4e0258f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoData.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.core.decoder.glide + +import android.os.ParcelFileDescriptor + +internal data class VerifiedVideoData( + val descriptor: ParcelFileDescriptor, +) : GalleryMediaData { + + override fun close() { + descriptor.close() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoDecoder.kt new file mode 100644 index 0000000000..05f41967c1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/VerifiedVideoDecoder.kt @@ -0,0 +1,27 @@ +package com.dot.gallery.core.decoder.glide + +import android.graphics.Bitmap +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.VideoDecoder + +internal class VerifiedVideoDecoder( + bitmapPool: BitmapPool +) : ResourceDecoder { + private val delegate = VideoDecoder.parcel(bitmapPool) + + override fun handles(source: VerifiedVideoData, options: Options): Boolean { + return true + } + + override fun decode( + source: VerifiedVideoData, + width: Int, + height: Int, + options: Options, + ): Resource? { + return delegate.decode(source.descriptor, width, height, options) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/isEncryptedVaultFile.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/isEncryptedVaultFile.kt deleted file mode 100644 index c545aa5bfe..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/glide/isEncryptedVaultFile.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.dot.gallery.core.decoder.glide - -import android.content.Context -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import java.io.File - -fun isEncryptedVaultFile(file: File): Boolean = - file.path.contains(BuildConfig.APPLICATION_ID) && file.extension == "enc" - -/** - * Decrypts a vault file and returns bytes + mime type. - * Handles both portable (VLTv1) and legacy (EncryptedFile) formats. - */ -fun decryptVaultFile(file: File, context: Context): EncryptedMediaStream { - val keychainHolder = KeychainHolder(context) - val decrypted = keychainHolder.decryptVaultMedia(file) - return EncryptedMediaStream( - bytes = decrypted.readBytes(), - mimeType = decrypted.mimeType, - isVideo = decrypted.mimeType.startsWith("video") - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decryption/DecryptManager.kt b/app/src/main/kotlin/com/dot/gallery/core/decryption/DecryptManager.kt deleted file mode 100644 index b05dbf7599..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/decryption/DecryptManager.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.dot.gallery.core.decryption - -import android.content.Context -import androidx.collection.LruCache -import com.dot.gallery.core.metrics.MetricsCollector -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -data class DecryptResult(val bytes: ByteArray, val mimeType: String) - -/** Metadata cached to avoid re-decrypting large files just for header info. */ -data class MediaMetadataCacheEntry( - val path: String, - val mimeType: String, - val width: Int?, - val height: Int?, - val durationMs: Long? -) - -@Singleton -class DecryptManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val metrics: MetricsCollector -) { - // Small LRU for decrypted byte arrays (only small items inserted) - private val lru = object : LruCache(32) { - override fun sizeOf(key: String, value: DecryptResult): Int = value.bytes.size - } - private val inFlight = ConcurrentHashMap Unit>>() - private val keychainHolder by lazy { KeychainHolder(context) } - - fun decrypt(file: File): DecryptResult { - val key = hash(file) - lru[key]?.let { - metrics.incLruHit() - return it - } - metrics.incLruMiss() - // Single-flight: if already decrypting, wait via callback list - val callbacks = inFlight.computeIfAbsent(key) { mutableListOf() } - if (callbacks.isNotEmpty()) { - var result: DecryptResult? = null - val latch = java.util.concurrent.CountDownLatch(1) - synchronized(callbacks) { - callbacks += { - result = it - latch.countDown() - } - } - metrics.incDecryptWaiters(1) - latch.await() - return result!! - } - // We are first owner - var result: DecryptResult? = null - try { - metrics.incDecryptInvocation() - val decrypted = keychainHolder.decryptVaultMedia(file) - result = DecryptResult(decrypted.readBytes(), decrypted.mimeType) - // Only cache small results (< 2MB) to keep memory bounded - if (result.bytes.size <= 2 * 1024 * 1024) { - lru.put(key, result) - } - return result - } finally { - val list = inFlight.remove(key) - if (list != null && result != null) { - list.forEach { cb -> cb(result) } - } - } - } - - private fun hash(file: File): String { - val md = MessageDigest.getInstance("SHA-256") - md.update(file.path.encodeToByteArray()) - val digest = md.digest() - return digest.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } - } -} - -/** Simple file-based sidecar metadata cache (lazy). */ -@Singleton -class MediaMetadataSidecarCache @Inject constructor( - @param:ApplicationContext private val context: Context -) { - private val dir by lazy { File(context.cacheDir, "meta_sidecar").apply { mkdirs() } } - - fun read(key: String): MediaMetadataCacheEntry? { - val f = File(dir, key) - if (!f.exists()) return null - return try { - val text = f.readText() - val parts = text.split('|') - MediaMetadataCacheEntry( - path = parts.getOrNull(0) ?: return null, - mimeType = parts.getOrNull(1) ?: return null, - width = parts.getOrNull(2)?.toIntOrNull(), - height = parts.getOrNull(3)?.toIntOrNull(), - durationMs = parts.getOrNull(4)?.toLongOrNull() - ) - } catch (_: Throwable) { null } - } - - fun write(entry: MediaMetadataCacheEntry) { - val key = hash(entry.path) - val f = File(dir, key) - runCatching { - f.writeText( - listOf( - entry.path, entry.mimeType, - entry.width?.toString() ?: "", - entry.height?.toString() ?: "", - entry.durationMs?.toString() ?: "" - ).joinToString("|") - ) - } - } - - fun keyForFile(file: File): String = hash(file.path) - - private fun hash(path: String): String { - val md = MessageDigest.getInstance("MD5") - md.update(path.encodeToByteArray()) - return md.digest().joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } - } -} - -// EntryPoint so non-Hilt classes (like static createEncryptedMediaSource) can access manager. -@dagger.hilt.EntryPoint -@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) -interface DecryptManagerEntryPoint { - fun decryptManager(): DecryptManager - fun sidecar(): MediaMetadataSidecarCache -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/ml/ImageEmbeddingGenerator.kt b/app/src/main/kotlin/com/dot/gallery/core/ml/ImageEmbeddingGenerator.kt new file mode 100644 index 0000000000..f0ad00fd33 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/ml/ImageEmbeddingGenerator.kt @@ -0,0 +1,50 @@ +package com.dot.gallery.core.ml + +import android.graphics.Bitmap +import com.dot.gallery.feature_node.presentation.search.helpers.SearchVisionHelper +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow + +internal interface ImageEmbeddingGenerator { + + val status: StateFlow + + fun openSession(): ImageEmbeddingSession +} + +internal interface ImageEmbeddingSession : AutoCloseable { + + fun generate(bitmap: Bitmap): FloatArray +} + +internal class ImageEmbeddingGeneratorImpl @Inject constructor( + private val modelManager: ModelManager, +) : ImageEmbeddingGenerator { + private val visionHelper by lazy { SearchVisionHelper(modelManager) } + + override val status: StateFlow = modelManager.status + + override fun openSession(): ImageEmbeddingSession { + return ImageEmbeddingSessionImpl( + visionHelper = visionHelper, + session = visionHelper.setupVisionSession(), + ) + } + + private class ImageEmbeddingSessionImpl( + private val visionHelper: SearchVisionHelper, + private val session: ManagedOrtSession, + ) : ImageEmbeddingSession { + + override fun generate(bitmap: Bitmap): FloatArray { + return visionHelper.getImageEmbedding( + session = session, + bitmap = bitmap, + ) + } + + override fun close() { + session.close() + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/ml/ManagedOrtSession.kt b/app/src/main/kotlin/com/dot/gallery/core/ml/ManagedOrtSession.kt new file mode 100644 index 0000000000..dcfafac113 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/ml/ManagedOrtSession.kt @@ -0,0 +1,64 @@ +package com.dot.gallery.core.ml + +import ai.onnxruntime.OnnxTensorLike +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtException +import ai.onnxruntime.OrtSession +import android.util.Log +import java.util.concurrent.locks.ReentrantReadWriteLock + +class ManagedOrtSession internal constructor( + internal val environment: OrtEnvironment, + private val session: OrtSession, + private val options: OrtSession.SessionOptions, +) : AutoCloseable { + private val lock = ReentrantReadWriteLock(true) + private var closed = false + + fun run( + inputs: Map, + block: (OrtSession.Result) -> T, + ): T { + val readLock = lock.readLock() + readLock.lock() + try { + if (closed) { + throw OrtException("ONNX session is closed") + } + session.run(inputs).use { result -> + return block(result) + } + } finally { + readLock.unlock() + } + } + + override fun close() { + val writeLock = lock.writeLock() + writeLock.lock() + try { + if (closed) { + return + } + + closed = true + + try { + session.close() + } catch (exception: OrtException) { + Log.e(TAG, "Unable to close ONNX session", exception) + } + try { + options.close() + } catch (exception: OrtException) { + Log.e(TAG, "Unable to close ONNX session options", exception) + } + } finally { + writeLock.unlock() + } + } + + private companion object { + private const val TAG = "ManagedOrtSession" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/ml/ModelManager.kt b/app/src/main/kotlin/com/dot/gallery/core/ml/ModelManager.kt index 5d731187f7..bd62fc42b4 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/ml/ModelManager.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/ml/ModelManager.kt @@ -5,14 +5,17 @@ package com.dot.gallery.core.ml -import android.Manifest import android.content.Context -import android.content.pm.PackageManager -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.printInfo import com.dot.gallery.feature_node.presentation.util.printWarning import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,253 +23,126 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File -import java.security.MessageDigest -import javax.inject.Inject -import javax.inject.Singleton - -data class DownloadInfo( - val speed: Long = 0L, - val downloadedBytes: Long = 0L, - val totalBytes: Long = 0L, - val currentFile: String = "" -) - -data class ModelFileInfo( - val name: String, - val size: Long, - val sha256: String, - val verified: Boolean -) enum class ModelStatus { - NOT_INSTALLED, - COPYING, - DOWNLOADING, + CHECKING, READY, - ERROR + ERROR, } @Singleton class ModelManager @Inject constructor( - @param:ApplicationContext private val context: Context + @param:ApplicationContext private val context: Context, ) { - private val _status = MutableStateFlow(ModelStatus.NOT_INSTALLED) + private val _status = MutableStateFlow(ModelStatus.CHECKING) val status: StateFlow = _status.asStateFlow() - private val _downloadProgress = MutableStateFlow(0f) - val downloadProgress: StateFlow = _downloadProgress.asStateFlow() - - private val _errorMessage = MutableStateFlow(null) - val errorMessage: StateFlow = _errorMessage.asStateFlow() - - private val _downloadInfo = MutableStateFlow(DownloadInfo()) - val downloadInfo: StateFlow = _downloadInfo.asStateFlow() - private val mutex = Mutex() - val isReady: Boolean get() = _status.value == ModelStatus.READY - - /** - * Whether the app has INTERNET permission declared in its manifest. - * When false, AI model download and all dependent features (categories, AI search) - * should be hidden/disabled since models cannot be downloaded. - */ - val hasInternetPermission: Boolean by lazy { - context.packageManager.checkPermission( - Manifest.permission.INTERNET, - context.packageName - ) == PackageManager.PERMISSION_GRANTED - } - - val modelsDir: File get() = File(context.filesDir, MODELS_DIR) - - /** - * Initialize models on app start. - * For withML builds: copies bundled assets to filesDir if not already present. - * For noML builds: checks if models have been previously downloaded. - */ - suspend fun initializeModels() = mutex.withLock { - withContext(Dispatchers.IO) { - if (checkModelsPresent()) { - _status.value = ModelStatus.READY - printInfo("ModelManager: Models already present in filesDir") - return@withContext - } + val isReady: Boolean + get() { + return _status.value == ModelStatus.READY + } - if (BuildConfig.ML_MODELS_BUNDLED) { - copyBundledModels() - } else { - _status.value = ModelStatus.NOT_INSTALLED - printInfo("ModelManager: Models not installed (noML build)") + suspend fun refreshStatus() { + mutex.withLock { + withContext(Dispatchers.IO) { + refreshStatusFromBundledAssets() } } } - /** - * Check if all required model files are present and non-empty. - */ fun checkModelsPresent(): Boolean { return REQUIRED_FILES.all { fileName -> - val file = File(modelsDir, fileName) - file.exists() && file.length() > 0 + runCatching { + getBundledAssetSize(name = fileName) > 0L + }.getOrElse { exception -> + printWarning("ModelManager: asset $fileName is not openable: ${exception.message}") + false + } } } - /** - * Get a model file by name. - * @throws ModelsNotAvailableException if models are not installed. - */ - fun getModelFile(name: String): File { + fun withMappedBundledModel(name: String, block: (MappedByteBuffer) -> T): T { if (!isReady) throw ModelsNotAvailableException() - val file = File(modelsDir, name) - if (!file.exists()) throw ModelsNotAvailableException("Model file not found: $name") - return file - } + requireKnownModel(name = name) - /** - * Get total installed model size in bytes. - */ - fun getInstalledSize(): Long { - if (!checkModelsPresent()) return 0L - return REQUIRED_FILES.sumOf { File(modelsDir, it).length() } - } - - /** - * Get detailed info (name, size, SHA-256) for each installed model file. - */ - fun getFileInfos(): List { - if (!checkModelsPresent()) return emptyList() - return REQUIRED_FILES.map { fileName -> - val file = File(modelsDir, fileName) - val hash = file.sha256() - ModelFileInfo( - name = fileName, - size = file.length(), - sha256 = hash, - verified = EXPECTED_CHECKSUMS[fileName] == hash - ) - } - } + val mappedBuffer = try { + context.assets.openFd(name).use { descriptor -> + val length = descriptor.length + if (length <= 0L) { + throw ModelsNotAvailableException(message = "Model asset is empty: $name") + } - private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { stream -> - val buffer = ByteArray(65536) - var read: Int - while (stream.read(buffer).also { read = it } != -1) { - digest.update(buffer, 0, read) + FileInputStream(descriptor.fileDescriptor).channel.use { channel -> + channel.map( + FileChannel.MapMode.READ_ONLY, + descriptor.startOffset, + length, + ) + } } + } catch (exception: IOException) { + throw ModelsNotAvailableException( + message = "Model asset is unavailable: $name", + cause = exception, + ) } - return digest.digest().joinToString("") { "%02x".format(it) } - } - /** - * Delete all downloaded/copied model files. - */ - suspend fun deleteModels() = mutex.withLock { - withContext(Dispatchers.IO) { - if (modelsDir.exists()) { - modelsDir.deleteRecursively() - printInfo("ModelManager: Models deleted") - } - _status.value = ModelStatus.NOT_INSTALLED - _downloadProgress.value = 0f - _errorMessage.value = null - _downloadInfo.value = DownloadInfo() - } + return block(mappedBuffer) } - /** - * Called by ModelDownloadWorker to update download progress. - */ - fun updateDownloadProgress(progress: Float) { - _downloadProgress.value = progress - _status.value = ModelStatus.DOWNLOADING - } + fun openBundledModelInputStream(name: String): InputStream { + if (!isReady) throw ModelsNotAvailableException() + requireKnownModel(name = name) - fun updateDownloadInfo(info: DownloadInfo) { - _downloadInfo.value = info + try { + return context.assets.open(name) + } catch (exception: IOException) { + throw ModelsNotAvailableException( + message = "Model asset is unavailable: $name", + cause = exception, + ) + } } - /** - * Called by ModelDownloadWorker when download completes successfully. - */ - fun onDownloadComplete() { + private fun refreshStatusFromBundledAssets() { if (checkModelsPresent()) { _status.value = ModelStatus.READY - _downloadProgress.value = 100f - _errorMessage.value = null - printInfo("ModelManager: Download complete, models ready") + printInfo("ModelManager: Bundled models are available") } else { _status.value = ModelStatus.ERROR - _errorMessage.value = "Download completed but model files are missing" - printWarning("ModelManager: Download completed but validation failed") + printWarning("ModelManager: Bundled models are unavailable") } } - /** - * Called by ModelDownloadWorker on failure. - */ - fun onDownloadFailed(error: String) { - _status.value = ModelStatus.ERROR - _errorMessage.value = error - _downloadProgress.value = 0f - printWarning("ModelManager: Download failed: $error") + private fun getBundledAssetSize(name: String): Long { + requireKnownModel(name = name) + return context.assets.openFd(name).use { descriptor -> + descriptor.length + } } - /** - * Copy bundled assets to filesDir (withML builds only). - */ - private suspend fun copyBundledModels() { - _status.value = ModelStatus.COPYING - try { - modelsDir.mkdirs() - val assetManager = context.assets - val totalFiles = REQUIRED_FILES.size - REQUIRED_FILES.forEachIndexed { index, fileName -> - val destFile = File(modelsDir, fileName) - if (!destFile.exists() || destFile.length() == 0L) { - printDebug("ModelManager: Copying asset $fileName to filesDir") - assetManager.open(fileName).use { input -> - destFile.outputStream().use { output -> - input.copyTo(output, bufferSize = 65536) - } - } - } - _downloadProgress.value = ((index + 1).toFloat() / totalFiles) * 100f - } - _status.value = ModelStatus.READY - printInfo("ModelManager: Bundled models copied to filesDir") - } catch (e: Exception) { - _status.value = ModelStatus.ERROR - _errorMessage.value = "Failed to copy bundled models: ${e.message}" - printWarning("ModelManager: Failed to copy bundled models: ${e.message}") - } + private fun requireKnownModel(name: String) { + require(name in REQUIRED_FILES) { "Unknown model file: $name" } } companion object { - const val MODELS_DIR = "models/clip" - val REQUIRED_FILES = listOf( "visual_quant.onnx", "textual_quant.onnx", "vocab.json", - "merges.txt" - ) - - const val BASE_DOWNLOAD_URL = - "https://raw.githubusercontent.com/IacobIonut01/ReFra/refs/heads/main/ml-models/src/main/assets/" - - val EXPECTED_CHECKSUMS = mapOf( - "visual_quant.onnx" to "a2fbb26b5f6ab5c79dd9bf99ab2dbac4711abc88dc2e20afc02a0827aa3d59c2", - "textual_quant.onnx" to "1ebb71a5ea1897823a829af8fc8168c5cfff761969bb62aee1fafdf5a2788aba", - "vocab.json" to "e089ad92ba36837a0d31433e555c8f45fe601ab5c221d4f607ded32d9f7a4349", - "merges.txt" to "9fd691f7c8039210e0fced15865466c65820d09b63988b0174bfe25de299051a" + "merges.txt", ) } } class ModelsNotAvailableException( - message: String = "ML models are not installed. Download them from Settings > Smart Features." -) : RuntimeException(message) + message: String = "ML models are unavailable in this build.", + cause: Throwable? = null, +) : RuntimeException(message, cause) + +class ModelInferenceException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/CollectionDialog.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/CollectionDialog.kt index 8350d84748..4a111b8c74 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/CollectionDialog.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/CollectionDialog.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt index 5259d93a8f..b058d5ea1e 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt @@ -38,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Album.rememberLastSort -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.OrderType @Composable fun FilterButton( diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt index b961ad6e4d..9ce465ee2b 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt @@ -22,10 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -37,8 +33,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,9 +50,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navArgument import com.dot.gallery.R import com.dot.gallery.core.Constants -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import kotlinx.coroutines.launch import com.dot.gallery.core.Constants.Animation.navigateInAnimation import com.dot.gallery.core.Constants.Animation.navigateUpAnimation import com.dot.gallery.core.Constants.Target.TARGET_FAVORITES @@ -70,21 +67,20 @@ import com.dot.gallery.core.presentation.vm.NavigationViewModel import com.dot.gallery.core.toggleNavigationBar import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.albums.AlbumGroupViewScreen -import com.dot.gallery.feature_node.presentation.albums.EditGroupScreen import com.dot.gallery.feature_node.presentation.albums.AlbumsScreen import com.dot.gallery.feature_node.presentation.albums.AlbumsViewModel +import com.dot.gallery.feature_node.presentation.albums.EditGroupScreen import com.dot.gallery.feature_node.presentation.albumtimeline.AlbumTimelineScreen import com.dot.gallery.feature_node.presentation.classifier.AddCategoryScreen -import com.dot.gallery.feature_node.presentation.classifier.CategoriesSettingsScreen -import com.dot.gallery.feature_node.presentation.classifier.CategoryEditorScreen -import com.dot.gallery.feature_node.presentation.classifier.EditCategoryScreen import com.dot.gallery.feature_node.presentation.classifier.CategoriesScreen +import com.dot.gallery.feature_node.presentation.classifier.CategoriesSettingsScreen import com.dot.gallery.feature_node.presentation.classifier.CategoriesViewModel -import com.dot.gallery.feature_node.presentation.location.LocationsScreen +import com.dot.gallery.feature_node.presentation.classifier.CategoryEditorScreen import com.dot.gallery.feature_node.presentation.classifier.CategoryViewModel import com.dot.gallery.feature_node.presentation.classifier.CategoryViewScreen -import com.dot.gallery.feature_node.presentation.collection.CollectionViewModel +import com.dot.gallery.feature_node.presentation.classifier.EditCategoryScreen import com.dot.gallery.feature_node.presentation.collection.CollectionAlbumSelectorScreen +import com.dot.gallery.feature_node.presentation.collection.CollectionViewModel import com.dot.gallery.feature_node.presentation.collection.CollectionViewScreen import com.dot.gallery.feature_node.presentation.dateformat.DateFormatScreen import com.dot.gallery.feature_node.presentation.exif.MetadataViewScreen @@ -92,34 +88,31 @@ import com.dot.gallery.feature_node.presentation.favorites.FavoriteScreen import com.dot.gallery.feature_node.presentation.help.HelpScreen import com.dot.gallery.feature_node.presentation.help.TutorialCategoryScreen import com.dot.gallery.feature_node.presentation.help.TutorialDetailScreen -import com.dot.gallery.feature_node.presentation.help.WhatsNewScreen import com.dot.gallery.feature_node.presentation.ignored.IgnoredScreen import com.dot.gallery.feature_node.presentation.library.LibraryScreen -import com.dot.gallery.feature_node.presentation.location.LocationTimelineScreen -import com.dot.gallery.feature_node.presentation.location.LocationsViewModel import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreenRoute import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.search.SearchScreen import com.dot.gallery.feature_node.presentation.search.SearchViewModel +import com.dot.gallery.feature_node.presentation.security.rememberBiometricState import com.dot.gallery.feature_node.presentation.settings.SettingsScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.ColorPaletteScreen - import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsGeneralScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsMediaViewerScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsNavigationScreen +import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsSecurityScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsSelectionActionsScreen -import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsTimelineAlbumsScreen -import com.dot.gallery.feature_node.presentation.settings.subsettings.EditBackupsViewerScreen -import com.dot.gallery.feature_node.presentation.settings.subsettings.AIModelsManagerScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsSmartFeaturesScreen +import com.dot.gallery.feature_node.presentation.settings.subsettings.SettingsTimelineAlbumsScreen import com.dot.gallery.feature_node.presentation.setup.SetupScreen import com.dot.gallery.feature_node.presentation.timeline.TimelineScreen import com.dot.gallery.feature_node.presentation.trashed.TrashedGridScreen +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.feature_node.presentation.vault.VaultScreen -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.launch @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalPermissionsApi::class) @Stable @@ -169,9 +162,6 @@ fun NavigationComp( var lastShouldDisplay by rememberSaveable { mutableStateOf(bottomNavEntries.find { item -> item.route == currentDest } != null) } - val shouldSkipAuth = rememberSaveable { - mutableStateOf(false) - } val allowBlur by rememberAllowBlur() LaunchedEffect(navBackStackEntry) { @@ -182,11 +172,8 @@ fun NavigationComp( bottomBarState.value = shouldDisplayBottomBar lastShouldDisplay = shouldDisplayBottomBar } - if (it != Screen.VaultScreen()) { - shouldSkipAuth.value = false - } systemBarFollowThemeState.value = - !((it.contains(Screen.MediaViewScreen.route) && allowBlur) || it.contains(Screen.VaultScreen())) + !(it.contains(Screen.MediaViewScreen.route) && allowBlur) } } val selector = LocalMediaSelector.current @@ -197,8 +184,6 @@ fun NavigationComp( val albumsState = navViewModel.albumsState.collectAsStateWithLifecycle() val timelineState = navViewModel.timelineMediaState.collectAsStateWithLifecycle() val metadataState = navViewModel.metadataState.collectAsStateWithLifecycle() - val vaultState = navViewModel.vaultState.collectAsStateWithLifecycle() - LaunchedEffect(permissionState) { navViewModel.updatePermissionGranted(permissionState) } @@ -272,9 +257,9 @@ fun NavigationComp( ) { val albumsViewModel = hiltViewModel() val scope = rememberCoroutineScope() - var pendingAlbum by remember { mutableStateOf(null) } + var pendingAlbum by remember { mutableStateOf(null) } var biometricAction by remember { mutableStateOf(null) } - var pendingLockAlbum by remember { mutableStateOf(null) } + var pendingLockAlbum by remember { mutableStateOf(null) } val securitySheetState = rememberAppBottomSheetState() val lockDisclaimerSheetState = rememberAppBottomSheetState() val biometricState = rememberBiometricState( @@ -297,7 +282,7 @@ fun NavigationComp( biometricAction = null } ) - val onAlbumClickWithLock: (com.dot.gallery.feature_node.domain.model.Album) -> Unit = remember(biometricState) { + val onAlbumClickWithLock: (com.dot.gallery.feature_node.data.model.Album) -> Unit = remember(biometricState) { { album -> if (album.isLocked) { if (!biometricState.isSupported) { @@ -312,7 +297,7 @@ fun NavigationComp( } } } - val onLockAlbumWithCheck: (com.dot.gallery.feature_node.domain.model.Album) -> Unit = remember(biometricState) { + val onLockAlbumWithCheck: (com.dot.gallery.feature_node.data.model.Album) -> Unit = remember(biometricState) { { album -> if (!biometricState.isSupported) { scope.launch { securitySheetState.show() } @@ -339,7 +324,7 @@ fun NavigationComp( var groupDialogMode by remember { mutableStateOf("create") } var groupDialogGroupId by remember { mutableStateOf(null) } var groupDialogInitialName by remember { mutableStateOf("") } - var pendingGroupAlbum by remember { mutableStateOf(null) } + var pendingGroupAlbum by remember { mutableStateOf(null) } val deleteGroupSheetState = rememberAppBottomSheetState() var pendingDeleteGroupId by remember { mutableStateOf(null) } @@ -656,7 +641,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -696,7 +680,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -724,7 +707,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -742,16 +724,6 @@ fun NavigationComp( ) } - composable( - route = Screen.VaultScreen() - ) { - VaultScreen( - paddingValues = paddingValues, - toggleRotate = toggleRotate, - shouldSkipAuth = shouldSkipAuth - ) - } - composable( route = Screen.LibraryScreen() ) { @@ -787,29 +759,6 @@ fun NavigationComp( CategoriesSettingsScreen() } - composable( - route = Screen.LocationsScreen.withMediaId(), - arguments = listOf( - navArgument("mediaId") { - type = NavType.LongType - defaultValue = -1L - } - ) - ) { backStackEntry -> - val initialMediaId = remember(backStackEntry) { - backStackEntry.arguments?.getLong("mediaId", -1L) ?: -1L - } - val locationsViewModel = hiltViewModel() - val locations by locationsViewModel.locations.collectAsStateWithLifecycle() - val geoMedia by locationsViewModel.geoMedia.collectAsStateWithLifecycle() - LocationsScreen( - metadataState = metadataState, - locations = locations, - geoMedia = geoMedia, - initialMediaId = initialMediaId - ) - } - composable( route = Screen.AddCategoryScreen() ) { @@ -960,7 +909,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -1000,7 +948,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -1074,7 +1021,6 @@ fun NavigationComp( mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this ) @@ -1092,8 +1038,8 @@ fun NavigationComp( composable(Screen.SettingsSmartFeaturesScreen()) { SettingsSmartFeaturesScreen() } - composable(Screen.AIModelsManagerScreen()) { - AIModelsManagerScreen() + composable(Screen.SettingsSecurityScreen()) { + SettingsSecurityScreen() } composable(Screen.SettingsAppearanceScreen()) { ColorPaletteScreen() @@ -1110,9 +1056,6 @@ fun NavigationComp( composable(Screen.SettingsSelectionActionsScreen()) { SettingsSelectionActionsScreen() } - composable(Screen.EditBackupsViewerScreen()) { - EditBackupsViewerScreen() - } composable(Screen.SearchScreen()) { SearchScreen( @@ -1157,41 +1100,6 @@ fun NavigationComp( TutorialDetailScreen(tipId = tipId) } - composable(Screen.WhatsNewScreen()) { - WhatsNewScreen() - } - - composable(Screen.LocationTimelineScreen.location()) { backStackEntry -> - val gpsLocationNameCity: String = remember(backStackEntry) { - backStackEntry.arguments?.getString("gpsLocationNameCity", "null").toString() - } - val gpsLocationNameCountry: String = remember(backStackEntry) { - backStackEntry.arguments?.getString("gpsLocationNameCountry", "null").toString() - } - - val locationsViewModel = - hiltViewModel( - key = "LocationViewModel", - creationCallback = { factory -> - factory.create(gpsLocationNameCity, gpsLocationNameCountry) - } - ) - val mediaState = locationsViewModel.mediaState.collectAsStateWithLifecycle() - val latestGeoMedia by locationsViewModel.latestGeoMedia.collectAsStateWithLifecycle() - - LocationTimelineScreen( - gpsLocationNameCity = gpsLocationNameCity, - gpsLocationNameCountry = gpsLocationNameCountry, - mediaState = mediaState, - latestGeoMedia = latestGeoMedia, - metadataState = metadataState, - paddingValues = paddingValues, - isScrolling = isScrolling, - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this - ) - } - composable( route = Screen.MetadataViewScreen.uriAndType(), arguments = listOf( @@ -1221,44 +1129,6 @@ fun NavigationComp( ) } - composable(Screen.MediaViewScreen.idAndLocation()) { backStackEntry -> - val mediaId: Long = remember(backStackEntry) { - backStackEntry.arguments?.getString("mediaId")?.toLongOrNull() ?: -1 - } - val gpsLocationNameCity: String = remember(backStackEntry) { - backStackEntry.arguments?.getString("gpsLocationNameCity", "null").toString() - } - val gpsLocationNameCountry: String = remember(backStackEntry) { - backStackEntry.arguments?.getString("gpsLocationNameCountry", "null").toString() - } - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.LocationTimelineScreen.location()) - } - - val locationsViewModel = - hiltViewModel( - viewModelStoreOwner = parentEntry, - key = "LocationViewModel", - creationCallback = { factory -> - factory.create(gpsLocationNameCity, gpsLocationNameCountry) - } - ) - val mediaState = locationsViewModel.mediaState.collectAsStateWithLifecycle() - - MediaViewScreenRoute( - toggleRotate = toggleRotate, - paddingValues = paddingValues, - mediaId = mediaId, - mediaState = mediaState, - metadataState = metadataState, - albumsState = albumsState, - vaultState = vaultState, - target = "location_${gpsLocationNameCity}_$gpsLocationNameCountry", - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this - ) - } - } } } @@ -1404,4 +1274,4 @@ private fun LockDisclaimerSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt index db03785185..9fcdf00609 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt @@ -6,11 +6,7 @@ package com.dot.gallery.core.presentation.components import android.app.Activity -import android.content.Intent -import android.provider.MediaStore -import android.widget.Toast import androidx.activity.compose.LocalActivity -import androidx.activity.result.IntentSenderRequest import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.slideInVertically @@ -41,7 +37,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Deselect import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -53,7 +48,6 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -84,20 +78,17 @@ import com.dot.gallery.R import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.Settings.Misc.rememberSelectionSheetConfig import com.dot.gallery.core.Settings.Misc.rememberShowFavoriteButton import com.dot.gallery.core.Settings.Misc.rememberShowSelectionTitles import com.dot.gallery.core.Settings.Misc.rememberTrashEnabled import com.dot.gallery.core.util.SdkCompat +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.ActionCondition -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.domain.model.SelectionAction -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.getUri import com.dot.gallery.feature_node.presentation.collection.CollectionViewModel import com.dot.gallery.feature_node.presentation.collection.components.AddToCollectionSheet import com.dot.gallery.feature_node.presentation.exif.CopyMediaSheet @@ -111,17 +102,12 @@ import com.dot.gallery.feature_node.presentation.util.launchEditIntent import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberMediaInfo -import com.dot.gallery.feature_node.presentation.util.shareMediaWithVaultSupport -import com.dot.gallery.feature_node.presentation.vault.VaultViewModel -import com.dot.gallery.feature_node.presentation.vault.components.AddToVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet +import com.dot.gallery.feature_node.presentation.util.shareMedia import com.dot.gallery.ui.theme.Shapes import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3Api::class) @Composable @@ -130,8 +116,6 @@ fun BoxScope.SelectionSheet( allMedia: MediaState, selectedMedia: SnapshotStateList, collectionId: Long? = null, - isInVault: Boolean = false, - currentVault: Vault? = null, ) { val albumsState = LocalMediaDistributor.current.albumsFlow.collectAsStateWithLifecycle() val selector = LocalMediaSelector.current @@ -146,14 +130,6 @@ fun BoxScope.SelectionSheet( val copySheetState = rememberAppBottomSheetState() var showCollectionSheet by rememberSaveable { mutableStateOf(false) } val collectionViewModel = hiltViewModel() - val vaultViewModel = hiltViewModel() - val vaultSheetState = rememberAppBottomSheetState() - // Tracks what the vault sheet is for: "hide", "copy", or "move" - var vaultSheetAction by rememberSaveable { mutableStateOf("hide") } - var vaultEncryptBehavior by Settings.Vault.rememberVaultEncryptBehavior() - val addToVaultSheetState = rememberAppBottomSheetState() - var hideTargetVault by remember { mutableStateOf(null) } - val vaults = vaultViewModel.vaultState.collectAsStateWithLifecycle() var showInfoSheet by rememberSaveable { mutableStateOf(false) } val metadataState = LocalMediaDistributor.current.metadataFlow.collectAsStateWithLifecycle( initialValue = MediaMetadataState() @@ -178,11 +154,8 @@ fun BoxScope.SelectionSheet( else Modifier.wrapContentWidth() } val config by rememberSelectionSheetConfig() - val sanitizedConfig = remember(config, isInVault) { - val base = config.sanitized() - if (isInVault && SelectionAction.ADD_TO_VAULT !in base.bottomActions) { - base.copy(bottomActions = base.bottomActions + SelectionAction.ADD_TO_VAULT) - } else base + val sanitizedConfig = remember(config) { + config.sanitized() } val showFavoriteButton by rememberShowFavoriteButton() val trashEnabled = rememberTrashEnabled() @@ -331,7 +304,7 @@ fun BoxScope.SelectionSheet( } // Middle actions — full-width pill buttons sanitizedConfig.middleActions.forEach { action -> - val isVisible = isActionVisible(action, collectionId, showFavoriteButton, isInVault) + val isVisible = isActionVisible(action, collectionId, showFavoriteButton) if (isVisible) { when (action) { SelectionAction.COLLECTION -> { @@ -388,7 +361,7 @@ fun BoxScope.SelectionSheet( horizontalArrangement = Arrangement.SpaceEvenly ) { sanitizedConfig.bottomActions.forEach { action -> - val isVisible = isActionVisible(action, collectionId, showFavoriteButton, isInVault) + val isVisible = isActionVisible(action, collectionId, showFavoriteButton) if (isVisible) { when (action) { SelectionAction.SHARE -> { @@ -398,7 +371,7 @@ fun BoxScope.SelectionSheet( title = stringResource(action.labelRes) ) { scope.launch { - context.shareMediaWithVaultSupport(selectedMedia, currentVault = currentVault) + context.shareMedia(selectedMedia) } } } @@ -420,12 +393,7 @@ fun BoxScope.SelectionSheet( tabletMode = tabletMode, title = stringResource(action.labelRes) ) { - if (isInVault) { - vaultSheetAction = "copy" - scope.launch { vaultSheetState.show() } - } else { - scope.launch { copySheetState.show() } - } + scope.launch { copySheetState.show() } } } SelectionAction.MOVE -> { @@ -434,74 +402,31 @@ fun BoxScope.SelectionSheet( tabletMode = tabletMode, title = stringResource(action.labelRes) ) { - if (isInVault) { - vaultSheetAction = "move" - scope.launch { vaultSheetState.show() } - } else { - scope.launch { moveSheetState.show() } - } + scope.launch { moveSheetState.show() } } } SelectionAction.TRASH -> { - if (isInVault) { - SelectionBarColumn( - imageVector = action.icon, - tabletMode = tabletMode, - title = stringResource(R.string.trash_delete) - ) { - scope.launch { - val vault = currentVault ?: vaultViewModel.currentVault.value ?: return@launch - selectedMedia.filterIsInstance().forEach { media -> - vaultViewModel.deleteMedia(vault, media) {} - } - selector.clearSelection() - } - } - } else { - val trashEnabledRes = remember(trashEnabled) { - if (trashEnabled.value) R.string.trash else R.string.trash_delete - } - SelectionBarColumn( - imageVector = action.icon, - tabletMode = tabletMode, - title = stringResource(id = trashEnabledRes), - onItemLongClick = { - scope.launch { - shouldMoveToTrash = false - trashSheetState.show() - } - }, - onItemClick = { - scope.launch { - shouldMoveToTrash = true - trashSheetState.show() - } - } - ) + val trashEnabledRes = remember(trashEnabled) { + if (trashEnabled.value) R.string.trash else R.string.trash_delete } - } - SelectionAction.ADD_TO_VAULT -> { SelectionBarColumn( - imageVector = if (isInVault) Icons.Outlined.Restore else action.icon, + imageVector = action.icon, tabletMode = tabletMode, - title = stringResource( - if (isInVault) R.string.restore else action.labelRes - ) - ) { - if (isInVault) { + title = stringResource(id = trashEnabledRes), + onItemLongClick = { scope.launch { - val vault = currentVault ?: vaultViewModel.currentVault.value ?: return@launch - selectedMedia.filterIsInstance().forEach { media -> - vaultViewModel.restoreMedia(vault, media) {} - } - selector.clearSelection() + shouldMoveToTrash = false + trashSheetState.show() + } + }, + onItemClick = { + scope.launch { + shouldMoveToTrash = true + trashSheetState.show() } - } else { - vaultSheetAction = "hide" - scope.launch { vaultSheetState.show() } } - } + ) } SelectionAction.EDIT -> { SelectionBarColumn( @@ -534,107 +459,6 @@ fun BoxScope.SelectionSheet( } } - SelectVaultSheet( - state = vaultSheetState, - vaultState = vaults.value, - excludeVault = if (isInVault) vaultViewModel.currentVault.value else null, - onVaultSelected = { targetVault -> - scope.launch { - when (vaultSheetAction) { - "copy", "move" -> { - val isCopy = vaultSheetAction == "copy" - val sourceVault = vaultViewModel.currentVault.value ?: return@launch - val mediaToTransfer = selectedMedia.filterIsInstance() - for (media in mediaToTransfer) { - vaultViewModel.transferMedia(sourceVault, targetVault, media, copy = isCopy) - } - // Switch to target vault so the user sees the result - vaultViewModel.currentVault.value = targetVault - } - else -> { - // Regular hide: encrypt into selected vault - when (vaultEncryptBehavior) { - Settings.Vault.ENCRYPT_DELETE -> { - Toast.makeText(context, context.getString(R.string.vault_hide_in_progress), Toast.LENGTH_SHORT).show() - vaultViewModel.encryptAndRequestDeletion( - targetVault, - selectedMedia.map { it.getUri() } - ) - } - Settings.Vault.ENCRYPT_KEEP -> { - Toast.makeText(context, context.getString(R.string.vault_hide_in_progress), Toast.LENGTH_SHORT).show() - vaultViewModel.addMediaKeepOriginals( - targetVault, - selectedMedia.map { it.getUri() } - ) - } - else -> { - hideTargetVault = targetVault - addToVaultSheetState.show() - return@launch // Don't clear selection yet - } - } - } - } - selector.clearSelection() - } - } - ) - - AddToVaultSheet( - state = addToVaultSheetState, - onEncryptAndDelete = { - val vault = hideTargetVault ?: return@AddToVaultSheet - Toast.makeText(context, context.getString(R.string.vault_hide_in_progress), Toast.LENGTH_SHORT).show() - scope.launch { - vaultViewModel.encryptAndRequestDeletion(vault, selectedMedia.map { it.getUri() }) - selector.clearSelection() - } - }, - onEncryptAndKeep = { - val vault = hideTargetVault ?: return@AddToVaultSheet - Toast.makeText(context, context.getString(R.string.vault_hide_in_progress), Toast.LENGTH_SHORT).show() - scope.launch { - vaultViewModel.addMediaKeepOriginals(vault, selectedMedia.map { it.getUri() }) - selector.clearSelection() - } - }, - onBehaviorChanged = { vaultEncryptBehavior = it } - ) - - val hideResult = rememberActivityResult(onResultOk = {}) - // Show user feedback (Toast) for hide operations - LaunchedEffect(Unit) { - vaultViewModel.userMessage.collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - // Collect deletion batches for permission request - LaunchedEffect(Unit) { - vaultViewModel.pendingDeletions.collect { leftovers -> - if (leftovers.isNotEmpty()) { - if (SdkCompat.supportsMediaStoreRequests) { - val intentSender = MediaStore.createDeleteRequest( - context.contentResolver, - leftovers - ).intentSender - val senderRequest = IntentSenderRequest.Builder(intentSender) - .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) - .build() - hideResult.launch(senderRequest) - } else { - withContext(Dispatchers.IO) { - leftovers.forEach { uri -> - runCatching { - context.contentResolver.delete(uri, null, null) - } - } - } - } - } - } - } - if (albumsState.value.albums.isNotEmpty()) { MoveMediaSheet( sheetState = moveSheetState, @@ -730,18 +554,7 @@ private fun isActionVisible( action: SelectionAction, collectionId: Long?, showFavoriteButton: Boolean, - isInVault: Boolean = false, ): Boolean { - if (isInVault) { - // In vault: only allow close, select_all, share, trash (delete), restore (ADD_TO_VAULT) - return action in setOf( - SelectionAction.CLOSE, - SelectionAction.SELECT_ALL, - SelectionAction.SHARE, - SelectionAction.TRASH, - SelectionAction.ADD_TO_VAULT, - ) - } return when (action.requiresCondition) { ActionCondition.NONE -> true ActionCondition.SUPPORTS_FAVORITES -> showFavoriteButton && SdkCompat.supportsFavorites @@ -970,4 +783,4 @@ private fun RowScope.SelectionBarColumn( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt index 4956ac474d..beaf29917a 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,10 +34,7 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign @@ -58,64 +54,62 @@ fun SetupWizard( subtitle: String, contentPadding: Dp = 32.dp, content: @Composable () -> Unit, - bottomBar: @Composable RowScope.() -> Unit -) = SetupWizard( - modifier = modifier, - iconComponent = { modifier, color -> - Icon( - imageVector = icon, - contentDescription = null, - modifier = modifier, - tint = color - ) - }, - title = title, - subtitle = subtitle, - contentPadding = contentPadding, - content = content, - bottomBar = bottomBar -) + bottomBar: @Composable RowScope.() -> Unit, +) { + SetupWizardContent( + modifier = modifier, + iconComponent = { iconModifier, color -> + Icon( + imageVector = icon, + contentDescription = null, + modifier = iconModifier, + tint = color, + ) + }, + title = title, + subtitle = subtitle, + contentPadding = contentPadding, + content = content, + bottomBar = bottomBar, + ) +} @Composable fun SetupWizard( modifier: Modifier = Modifier, - painter: Painter, title: String, subtitle: String, contentPadding: Dp = 32.dp, content: @Composable () -> Unit, - bottomBar: @Composable RowScope.() -> Unit -) = SetupWizard( - modifier = modifier, - iconComponent = { modifier, color -> - Image( - painter = painter, - contentDescription = null, - modifier = modifier, - contentScale = ContentScale.Crop, - colorFilter = ColorFilter.tint(color) - ) - }, - title = title, - subtitle = subtitle, - contentPadding = contentPadding, - content = content, - bottomBar = bottomBar -) + bottomBar: @Composable RowScope.() -> Unit, +) { + SetupWizardContent( + modifier = modifier, + iconComponent = null, + title = title, + subtitle = subtitle, + contentPadding = contentPadding, + content = content, + bottomBar = bottomBar, + ) +} @Composable -fun SetupWizard( +private fun SetupWizardContent( modifier: Modifier = Modifier, - iconComponent: @Composable (Modifier, Color) -> Unit, + iconComponent: (@Composable (Modifier, Color) -> Unit)?, title: String, subtitle: String, contentPadding: Dp = 32.dp, content: @Composable () -> Unit, - bottomBar: @Composable RowScope.() -> Unit + bottomBar: @Composable RowScope.() -> Unit, ) { val colorPrimary = MaterialTheme.colorScheme.primaryContainer val colorTertiary = MaterialTheme.colorScheme.tertiaryContainer val containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + val hasIcon = iconComponent != null + val topPadding = if (hasIcon) 24.dp else 16.dp + val verticalSpacing = if (hasIcon) 32.dp else 24.dp val transition = rememberInfiniteTransition() val fraction by transition.animateFloat( @@ -165,15 +159,17 @@ fun SetupWizard( .background(Color.Transparent) .fillMaxWidth() .padding(paddingValues) - .padding(top = 24.dp) + .padding(top = topPadding) .verticalScroll(state = rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(verticalSpacing), horizontalAlignment = Alignment.CenterHorizontally ) { - iconComponent( - Modifier.size(64.dp), - MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) + if (iconComponent != null) { + iconComponent( + Modifier.size(64.dp), + MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + ) + } Text( modifier = Modifier.padding(horizontal = 24.dp), text = buildAnnotatedString { @@ -228,4 +224,4 @@ private fun Preview() { } ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt index 2d554f743b..4fb4eee777 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt @@ -6,7 +6,6 @@ package com.dot.gallery.core.presentation.components.util import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -39,17 +38,15 @@ fun StickyHeaderGrid( if (showSearchBar) 0 else 64.dp.roundToPx() + statusBarHeightPx } } - val offsetAnimation by animateIntOffsetAsState( - remember(headerOffset, toolbarOffset) { - IntOffset( - x = 0, - y = headerOffset + toolbarOffset - ) - }, label = "offsetAnimation" - ) + val totalOffsetY = remember(headerOffset, toolbarOffset) { + IntOffset( + x = 0, + y = headerOffset + toolbarOffset + ) + } val alphaAnimation by animateFloatAsState( - targetValue = remember(offsetAnimation) { if (offsetAnimation.y < -100) 0f else 1f }, + targetValue = remember(totalOffsetY) { if (totalOffsetY.y < -100) 0f else 1f }, label = "alphaAnimation", animationSpec = tween(100, 10), ) @@ -59,7 +56,7 @@ fun StickyHeaderGrid( Box( modifier = Modifier .alpha(alphaAnimation) - .offset { offsetAnimation } + .offset { totalOffsetY } ) { stickyHeader() } diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/vm/NavigationViewModel.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/vm/NavigationViewModel.kt index 0b835cf57a..d85282b327 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/vm/NavigationViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/vm/NavigationViewModel.kt @@ -6,13 +6,12 @@ import com.dot.gallery.core.MediaDistributor import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.VaultState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class NavigationViewModel @Inject constructor( @@ -43,10 +42,6 @@ class NavigationViewModel @Inject constructor( viewModelScope, SharingStarted.WhileSubscribed(5_000), MediaMetadataState() ) - val vaultState = distributor.vaultsMediaFlow.stateIn( - viewModelScope, SharingStarted.WhileSubscribed(), VaultState() - ) - fun updateGroupByMonth(value: Boolean) { viewModelScope.launch { distributor.groupByMonth = value @@ -59,4 +54,4 @@ class NavigationViewModel @Inject constructor( } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransfer.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransfer.kt new file mode 100644 index 0000000000..f0be003fdd --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransfer.kt @@ -0,0 +1,22 @@ +package com.dot.gallery.core.sandbox + +import com.dot.gallery.core.util.MAX_ENCODED_MEDIA_BYTES +import com.dot.gallery.core.util.SizeLimitedInputStream +import java.io.InputStream +import java.io.OutputStream + +internal fun transferEncodedMedia( + inputStream: InputStream, + outputStream: OutputStream, + maximumBytes: Long = MAX_ENCODED_MEDIA_BYTES, +): Long { + return SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = maximumBytes, + ).copyTo( + out = outputStream, + bufferSize = TRANSFER_BUFFER_BYTES, + ) +} + +private const val TRANSFER_BUFFER_BYTES = 64 * 1024 diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnection.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnection.kt new file mode 100644 index 0000000000..5e57511a34 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnection.kt @@ -0,0 +1,149 @@ +package com.dot.gallery.core.sandbox + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.Messenger +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +@Singleton +internal class IsolatedDecoderConnection @Inject constructor( + @ApplicationContext context: Context, +) : IsolatedServiceConnection(context = context, instanceName = null) + +@Singleton +internal class MediaAnalysisDecoderConnection @Inject constructor( + @ApplicationContext context: Context, +) : IsolatedServiceConnection(context = context, instanceName = MEDIA_ANALYSIS_INSTANCE_NAME) { + + companion object { + private const val MEDIA_ANALYSIS_INSTANCE_NAME = "media_analysis" + } +} + +internal open class IsolatedServiceConnection( + private val context: Context, + private val instanceName: String?, +) { + @Volatile + private var bindingRegistered = false + + private val connectionMutex = Mutex() + private val serviceMessenger = MutableStateFlow(null) + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + serviceMessenger.value = binder?.let(::Messenger) + Log.d(TAG, "Service connected") + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceMessenger.value = null + Log.w(TAG, "Service disconnected") + } + + override fun onBindingDied(name: ComponentName?) { + releaseBinding() + Log.w(TAG, "Service binding died") + } + + override fun onNullBinding(name: ComponentName?) { + releaseBinding() + Log.w(TAG, "Service returned a null binding") + } + } + + suspend fun getMessenger(): Messenger? { + serviceMessenger.value?.let { messenger -> + return messenger + } + + return connectionMutex.withLock { + serviceMessenger.value?.let { messenger -> + return@withLock messenger + } + + val registered = withContext(Dispatchers.Main.immediate) { + registerBindingIfNeeded() + } + if (!registered) { + return@withLock null + } + + val messenger = withTimeoutOrNull(timeMillis = BIND_TIMEOUT_MILLIS) { + serviceMessenger.filterNotNull().first() + } + if (messenger == null) { + withContext(Dispatchers.Main.immediate) { + if (serviceMessenger.value == null) { + releaseBinding() + } + } + Log.w(TAG, "Bind timed out") + } + messenger + } + } + + fun unbind() { + releaseBinding() + } + + private fun registerBindingIfNeeded(): Boolean { + if (bindingRegistered) { + return true + } + + val intent = Intent(context, IsolatedDecoderService::class.java) + val bindingStarted = when (instanceName) { + null -> context.bindService( + intent, + serviceConnection, + Context.BIND_AUTO_CREATE, + ) + + else -> context.bindIsolatedService( + intent, + Context.BIND_AUTO_CREATE, + instanceName, + context.mainExecutor, + serviceConnection, + ) + } + bindingRegistered = bindingStarted + if (!bindingStarted) { + Log.w(TAG, "bindService returned false") + } + return bindingStarted + } + + private fun releaseBinding() { + serviceMessenger.value = null + if (!bindingRegistered) { + return + } + + bindingRegistered = false + runCatching { + context.unbindService(serviceConnection) + } + } + + companion object { + private const val TAG = "IsolatedDecoderConnection" + private const val BIND_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderService.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderService.kt new file mode 100644 index 0000000000..2b0ad87d7c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderService.kt @@ -0,0 +1,685 @@ +package com.dot.gallery.core.sandbox + +import android.app.Service +import android.content.Intent +import android.content.res.AssetFileDescriptor +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ImageDecoder +import android.graphics.Paint +import android.graphics.Rect +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.ParcelFileDescriptor +import android.os.SharedMemory +import android.system.OsConstants +import android.util.Log +import android.util.Size +import androidx.core.graphics.createBitmap +import com.awxkee.jxlcoder.JxlCoder +import com.caverock.androidsvg.SVG +import com.dot.gallery.core.decoder.ImageFileFormat +import com.dot.gallery.core.decoder.classifyImageHeader +import com.dot.gallery.core.decoder.preadImageHeader +import com.dot.gallery.core.util.MAX_ENCODED_MEDIA_BYTES +import com.dot.gallery.core.util.SizeLimitedInputStream +import com.radzivon.bartoshyk.avif.coder.HeifCoder +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.Locale +import java.util.concurrent.Executors +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +class IsolatedDecoderService : Service() { + private lateinit var messenger: Messenger + private val heifCoder = HeifCoder() + private val requestExecutor = Executors.newSingleThreadExecutor() + + override fun onCreate() { + super.onCreate() + messenger = Messenger(IncomingHandler(Looper.getMainLooper())) + } + + override fun onBind(intent: Intent?): IBinder { + return messenger.binder + } + + override fun onDestroy() { + requestExecutor.shutdownNow() + + super.onDestroy() + } + + private inner class IncomingHandler(looper: Looper) : Handler(looper) { + override fun handleMessage(message: Message) { + val replyTo = message.replyTo ?: return + val request = message.data + + request.classLoader = ParcelFileDescriptor::class.java.classLoader + + val requestType = message.what + + requestExecutor.execute { + val reply = Message.obtain().apply { + what = requestType + data = try { + when (requestType) { + MSG_GET_SIZE -> { + readImageSize(input = request) + } + + MSG_DECODE -> { + decodeImage(input = request) + } + + MSG_DECODE_PREVIEW -> { + decodePreview(input = request) + } + + else -> { + errorBundle(message = "Unsupported message: $requestType") + } + } + } catch (failure: Exception) { + Log.w(TAG, "Isolated decode request failed", failure) + errorBundle(message = failure.message ?: "Unknown decode error") + } finally { + closeInputDescriptor(data = request) + } + } + + try { + replyTo.send(reply) + } catch (_: Exception) { + // Client is gone. + } finally { + closeOutputSharedMemory(data = reply.data) + } + } + } + } + + private fun readImageSize(input: Bundle): Bundle { + val mimeType = normalizedMimeType(input.getString(KEY_MIME_TYPE, "")) + ?: return errorBundle(message = "Unsupported MIME type") + val encodedBytes = readInputBytes(input = input) + val size = getImageSize(bytes = encodedBytes, mimeType = mimeType) + ?: return errorBundle(message = "Failed to read image size") + validateOriginalSize(size = size) + + return Bundle().apply { + putInt(KEY_IMAGE_WIDTH, size.width) + putInt(KEY_IMAGE_HEIGHT, size.height) + } + } + + private fun decodeImage(input: Bundle): Bundle { + val mimeType = normalizedMimeType(input.getString(KEY_MIME_TYPE, "")) + ?: return errorBundle(message = "Unsupported MIME type") + val encodedBytes = readInputBytes(input = input) + val originalSize = getImageSize(bytes = encodedBytes, mimeType = mimeType) + ?: return errorBundle(message = "Failed to read image size") + validateOriginalSize(size = originalSize) + + val decodedBudget = input.getLong(KEY_MAX_DECODED_BYTES, 0L) + .coerceIn(minimumValue = MIN_DECODED_BITMAP_BYTES, maximumValue = MAX_DECODED_BITMAP_BYTES) + val targetSize = resolveTargetSize( + originalSize = originalSize, + requestedWidth = input.getInt(KEY_TARGET_WIDTH, 0), + requestedHeight = input.getInt(KEY_TARGET_HEIGHT, 0), + maximumDecodedBytes = decodedBudget, + ) + val bitmap = decodeBitmap( + bytes = encodedBytes, + mimeType = mimeType, + targetSize = targetSize, + ) ?: return errorBundle(message = "Decode failed for MIME type: $mimeType") + + return bitmapResult( + bitmap = bitmap, + originalSize = originalSize, + decodedBudget = decodedBudget, + ) + } + + private fun decodePreview(input: Bundle): Bundle { + val inputDescriptor = input + .getParcelable(KEY_INPUT_PFD, ParcelFileDescriptor::class.java) + ?: return errorBundle(message = "Missing input descriptor") + val encodedSize = inputDescriptor.statSize + if (encodedSize == 0L) { + return errorBundle(message = "Encoded media has an invalid size") + } + + val requestedWidth = input.getInt(KEY_TARGET_WIDTH, 0) + val requestedHeight = input.getInt(KEY_TARGET_HEIGHT, 0) + if (requestedWidth <= 0 || requestedHeight <= 0) { + return errorBundle(message = "Invalid preview dimensions") + } + val decodedBudget = input.getLong(KEY_MAX_DECODED_BYTES, 0L) + val requiredBytes = requestedWidth.toLong() * requestedHeight.toLong() * ARGB_BYTES_PER_PIXEL + if (requiredBytes <= 0L || decodedBudget < requiredBytes) { + return errorBundle(message = "Invalid preview memory budget") + } + + val header = runCatching { + inputDescriptor.fileDescriptor.preadImageHeader() + }.getOrDefault(defaultValue = byteArrayOf()) + val imageFormat = classifyImageHeader(header = header, length = header.size) + val preview = when { + imageFormat == ImageFileFormat.SVG -> decodeSvgPreview( + inputDescriptor = inputDescriptor, + encodedSize = encodedSize, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + ) + + imageFormat?.sandboxMimeType != null -> decodeSandboxedPreview( + inputDescriptor = inputDescriptor, + format = imageFormat, + encodedSize = encodedSize, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + ) + + imageFormat != null -> decodePlatformPreview( + inputDescriptor = inputDescriptor, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + ) + + input.getBoolean(KEY_IS_VIDEO, false) -> { + return Bundle().apply { + putBoolean(KEY_USE_VIDEO_THUMBNAIL, true) + } + } + + else -> null + } ?: return errorBundle(message = "Unable to decode media preview") + + val normalizedBitmap = preview.bitmap.toArgb8888() ?: run { + preview.bitmap.recycle() + return errorBundle(message = "Unable to normalize media preview") + } + if (normalizedBitmap !== preview.bitmap) { + preview.bitmap.recycle() + } + + return bitmapResult( + bitmap = normalizedBitmap, + originalSize = preview.originalSize, + decodedBudget = decodedBudget, + ) + } + + private fun decodeSvgPreview( + inputDescriptor: ParcelFileDescriptor, + encodedSize: Long, + requestedWidth: Int, + requestedHeight: Int, + ): PreviewBitmap? { + if (encodedSize > MAX_SVG_BYTES) { + return null + } + + val encodedBytes = readInputBytes( + inputDescriptor = inputDescriptor, + maximumBytes = MAX_SVG_BYTES, + ) + + val svg = ByteArrayInputStream(encodedBytes).use { inputStream -> + SVG.getFromInputStream(inputStream) + } + + val viewBox = svg.documentViewBox + val originalSize = Size( + resolveSvgDimension( + viewBoxDimension = viewBox?.width(), + documentDimension = svg.documentWidth, + ), + resolveSvgDimension( + viewBoxDimension = viewBox?.height(), + documentDimension = svg.documentHeight, + ), + ) + + validateOriginalSize(size = originalSize) + + if (viewBox == null) { + svg.setDocumentViewBox( + 0f, + 0f, + originalSize.width.toFloat(), + originalSize.height.toFloat(), + ) + } + + svg.setDocumentWidth("100%") + svg.setDocumentHeight("100%") + + val targetSize = resolvePreviewTargetSize( + originalSize = originalSize, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + maximumDecodedBytes = requestedWidth.toLong() * + requestedHeight.toLong() * MAXIMUM_PREVIEW_BYTES_PER_PIXEL, + ) + + val bitmap = createBitmap( + width = targetSize.width, + height = targetSize.height, + config = Bitmap.Config.ARGB_8888, + ) + + svg.renderToCanvas(Canvas(bitmap)) + + return PreviewBitmap( + bitmap = centerCrop( + bitmap = bitmap, + width = requestedWidth, + height = requestedHeight, + ), + originalSize = originalSize, + ) + } + + private fun resolveSvgDimension(viewBoxDimension: Float?, documentDimension: Float): Int { + val dimension = viewBoxDimension?.takeIf { value -> value.isFinite() && value > 0f } + ?: documentDimension.takeIf { value -> value.isFinite() && value > 0f } + ?: throw IOException("SVG has invalid dimensions") + + if (dimension > Int.MAX_VALUE.toFloat()) { + throw IOException("SVG dimensions exceed safety limit") + } + + return dimension.roundToInt().coerceAtLeast(minimumValue = 1) + } + + private fun decodeSandboxedPreview( + inputDescriptor: ParcelFileDescriptor, + format: ImageFileFormat, + encodedSize: Long, + requestedWidth: Int, + requestedHeight: Int, + ): PreviewBitmap? { + if (encodedSize > MAX_ENCODED_MEDIA_BYTES) { + return null + } + val mimeType = format.sandboxMimeType ?: return null + val encodedBytes = readInputBytes(inputDescriptor = inputDescriptor) + val originalSize = getImageSize(bytes = encodedBytes, mimeType = mimeType) ?: return null + + validateOriginalSize(size = originalSize) + + val targetSize = resolvePreviewTargetSize( + originalSize = originalSize, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + maximumDecodedBytes = requestedWidth.toLong() * requestedHeight.toLong() * 16L, + ) + + val decodedBitmap = decodeBitmap( + bytes = encodedBytes, + mimeType = mimeType, + targetSize = targetSize, + ) ?: return null + + return PreviewBitmap( + bitmap = centerCrop( + bitmap = decodedBitmap, + width = requestedWidth, + height = requestedHeight, + ), + originalSize = originalSize, + ) + } + + private fun decodePlatformPreview( + inputDescriptor: ParcelFileDescriptor, + requestedWidth: Int, + requestedHeight: Int, + ): PreviewBitmap? { + var originalSize: Size? = null + val source = ImageDecoder.createSource { + AssetFileDescriptor( + ParcelFileDescriptor.dup(inputDescriptor.fileDescriptor), + 0L, + inputDescriptor.statSize, + ) + } + + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, imageInfo, _ -> + validateOriginalSize(size = imageInfo.size) + originalSize = imageInfo.size + val targetSize = resolvePreviewTargetSize( + originalSize = imageInfo.size, + requestedWidth = requestedWidth, + requestedHeight = requestedHeight, + maximumDecodedBytes = requestedWidth.toLong() * + requestedHeight.toLong() * MAXIMUM_PREVIEW_BYTES_PER_PIXEL, + ) + decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE) + decoder.memorySizePolicy = ImageDecoder.MEMORY_POLICY_LOW_RAM + decoder.setTargetSize(targetSize.width, targetSize.height) + } + val validatedOriginalSize = originalSize ?: return null + return PreviewBitmap( + bitmap = centerCrop( + bitmap = bitmap, + width = requestedWidth, + height = requestedHeight, + ), + originalSize = validatedOriginalSize, + ) + } + + private fun centerCrop(bitmap: Bitmap, width: Int, height: Int): Bitmap { + if (bitmap.width == width && bitmap.height == height) { + return bitmap + } + val sourceAspectRatio = bitmap.width.toDouble() / bitmap.height.toDouble() + val targetAspectRatio = width.toDouble() / height.toDouble() + val sourceRect = when { + sourceAspectRatio > targetAspectRatio -> { + val cropWidth = (bitmap.height.toDouble() * targetAspectRatio) + .roundToInt() + .coerceIn(minimumValue = 1, maximumValue = bitmap.width) + val left = (bitmap.width - cropWidth) / 2 + Rect(left, 0, left + cropWidth, bitmap.height) + } + + else -> { + val cropHeight = (bitmap.width.toDouble() / targetAspectRatio) + .roundToInt() + .coerceIn(minimumValue = 1, maximumValue = bitmap.height) + val top = (bitmap.height - cropHeight) / 2 + Rect(0, top, bitmap.width, top + cropHeight) + } + } + return try { + createBitmap( + width = width, + height = height, + config = Bitmap.Config.ARGB_8888, + ).apply { + Canvas(this).drawBitmap( + bitmap, + sourceRect, + Rect(0, 0, width, height), + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG), + ) + } + } finally { + bitmap.recycle() + } + } + + private fun Bitmap.toArgb8888(): Bitmap? { + return when (config) { + Bitmap.Config.ARGB_8888 -> this + else -> copy(Bitmap.Config.ARGB_8888, false) + } + } + + private fun resolvePreviewTargetSize( + originalSize: Size, + requestedWidth: Int, + requestedHeight: Int, + maximumDecodedBytes: Long, + ): Size { + val fillScale = max( + requestedWidth.toDouble() / originalSize.width.toDouble(), + requestedHeight.toDouble() / originalSize.height.toDouble(), + ).coerceAtMost(maximumValue = 1.0) + val desiredWidth = max( + 1, + (originalSize.width.toDouble() * fillScale).roundToInt(), + ) + val desiredHeight = max( + 1, + (originalSize.height.toDouble() * fillScale).roundToInt(), + ) + val desiredBytes = desiredWidth.toLong() * desiredHeight.toLong() * ARGB_BYTES_PER_PIXEL + val budgetScale = when { + desiredBytes > maximumDecodedBytes -> sqrt( + maximumDecodedBytes.toDouble() / desiredBytes.toDouble(), + ) + + else -> 1.0 + } + return Size( + max(1, (desiredWidth.toDouble() * budgetScale).roundToInt()), + max(1, (desiredHeight.toDouble() * budgetScale).roundToInt()), + ) + } + + private fun bitmapResult( + bitmap: Bitmap, + originalSize: Size, + decodedBudget: Long, + ): Bundle { + return bitmap.useForResult { decodedBitmap -> + val pixelByteCount = decodedBitmap.byteCount + if (pixelByteCount <= 0 || pixelByteCount.toLong() > decodedBudget) { + return@useForResult errorBundle(message = "Decoded bitmap exceeds memory budget") + } + + val outputSharedMemory = SharedMemory.create("decoded_pixels", pixelByteCount) + try { + val outputBuffer = outputSharedMemory.mapReadWrite() + try { + decodedBitmap.copyPixelsToBuffer(outputBuffer) + } finally { + SharedMemory.unmap(outputBuffer) + } + if (!outputSharedMemory.setProtect(OsConstants.PROT_READ)) { + throw IOException("Failed to make decoded pixels read-only") + } + Bundle().apply { + putParcelable(KEY_OUTPUT_SHM, outputSharedMemory) + putInt(KEY_ORIGINAL_WIDTH, originalSize.width) + putInt(KEY_ORIGINAL_HEIGHT, originalSize.height) + putInt(KEY_DECODED_WIDTH, decodedBitmap.width) + putInt(KEY_DECODED_HEIGHT, decodedBitmap.height) + putInt(KEY_DECODED_BYTE_COUNT, pixelByteCount) + putString( + KEY_DECODED_CONFIG, + (decodedBitmap.config ?: Bitmap.Config.ARGB_8888).name, + ) + } + } catch (failure: Exception) { + outputSharedMemory.close() + throw failure + } + } + } + + private data class PreviewBitmap( + val bitmap: Bitmap, + val originalSize: Size, + ) + + private fun readInputBytes(input: Bundle): ByteArray { + val inputDescriptor = input + .getParcelable(KEY_INPUT_PFD, ParcelFileDescriptor::class.java) + ?: throw IOException("Missing input descriptor") + return readInputBytes(inputDescriptor = inputDescriptor) + } + + private fun readInputBytes( + inputDescriptor: ParcelFileDescriptor, + maximumBytes: Long = MAX_ENCODED_MEDIA_BYTES, + ): ByteArray { + val output = ByteArrayOutputStream(INITIAL_INPUT_CAPACITY_BYTES) + ParcelFileDescriptor.AutoCloseInputStream(inputDescriptor).use { inputStream -> + SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = maximumBytes, + ).copyTo( + out = output, + bufferSize = INPUT_BUFFER_BYTES, + ) + } + + if (output.size() == 0) { + throw IOException("Encoded image is empty") + } + return output.toByteArray() + } + + private fun getImageSize(bytes: ByteArray, mimeType: String): Size? { + return when { + mimeType in HEIF_MIME_TYPES -> heifCoder.getSize(bytes) + mimeType == JXL_MIME_TYPE -> JxlCoder.getSize(bytes) + else -> null + } + } + + private fun decodeBitmap(bytes: ByteArray, mimeType: String, targetSize: Size): Bitmap? { + return when { + mimeType in HEIF_MIME_TYPES -> { + heifCoder.decodeSampled(bytes, targetSize.width, targetSize.height) + } + + mimeType == JXL_MIME_TYPE -> { + JxlCoder.decodeSampled(bytes, targetSize.width, targetSize.height) + } + + else -> null + } + } + + private fun validateOriginalSize(size: Size) { + if (size.width <= 0 || size.height <= 0) { + throw IOException("Invalid image dimensions") + } + val pixelCount = size.width.toLong() * size.height.toLong() + if (pixelCount <= 0L || pixelCount > MAX_IMAGE_PIXELS) { + throw IOException("Image dimensions exceed safety limit") + } + } + + private fun resolveTargetSize( + originalSize: Size, + requestedWidth: Int, + requestedHeight: Int, + maximumDecodedBytes: Long, + ): Size { + val requestedScale = when { + requestedWidth > 0 && requestedHeight > 0 -> { + min( + requestedWidth.toDouble() / originalSize.width.toDouble(), + requestedHeight.toDouble() / originalSize.height.toDouble(), + ) + } + + requestedWidth > 0 -> requestedWidth.toDouble() / originalSize.width.toDouble() + requestedHeight > 0 -> requestedHeight.toDouble() / originalSize.height.toDouble() + else -> 1.0 + }.coerceAtMost(maximumValue = 1.0) + val requestedPixels = originalSize.width.toDouble() * originalSize.height.toDouble() * + requestedScale * requestedScale + val maximumPixels = maximumDecodedBytes.toDouble() / ARGB_BYTES_PER_PIXEL.toDouble() + val budgetScale = when { + requestedPixels > maximumPixels -> sqrt(maximumPixels / requestedPixels) + else -> 1.0 + } + val finalScale = requestedScale * budgetScale + return Size( + (originalSize.width.toDouble() * finalScale).roundToInt().coerceAtLeast(minimumValue = 1), + (originalSize.height.toDouble() * finalScale).roundToInt().coerceAtLeast(minimumValue = 1), + ) + } + + private fun normalizedMimeType(mimeType: String): String? { + val normalized = mimeType.substringBefore(';').trim().lowercase(Locale.ROOT) + return normalized.takeIf { value -> + value == JXL_MIME_TYPE || value in HEIF_MIME_TYPES + } + } + + private inline fun Bitmap.useForResult(block: (Bitmap) -> T): T { + return try { + block(this) + } finally { + recycle() + } + } + + private fun closeOutputSharedMemory(data: Bundle) { + data.classLoader = SharedMemory::class.java.classLoader + data.getParcelable(KEY_OUTPUT_SHM, SharedMemory::class.java)?.close() + } + + private fun closeInputDescriptor(data: Bundle) { + data.classLoader = ParcelFileDescriptor::class.java.classLoader + data.getParcelable(KEY_INPUT_PFD, ParcelFileDescriptor::class.java)?.let { descriptor -> + runCatching { + descriptor.close() + } + } + } + + private fun errorBundle(message: String): Bundle { + return Bundle().apply { + putBoolean(KEY_ERROR, true) + putString(KEY_ERROR_MESSAGE, message) + } + } + + companion object { + private const val MAXIMUM_PREVIEW_BYTES_PER_PIXEL = 16L + private const val TAG = "IsolatedDecoderService" + internal const val MSG_GET_SIZE = 1 + internal const val MSG_DECODE = 2 + internal const val MSG_DECODE_PREVIEW = 3 + + internal const val KEY_INPUT_PFD = "input_pfd" + internal const val KEY_MIME_TYPE = "mime_type" + internal const val KEY_IS_VIDEO = "is_video" + internal const val KEY_USE_VIDEO_THUMBNAIL = "use_video_thumbnail" + internal const val KEY_TARGET_WIDTH = "target_width" + internal const val KEY_TARGET_HEIGHT = "target_height" + internal const val KEY_MAX_DECODED_BYTES = "max_decoded_bytes" + + internal const val KEY_IMAGE_WIDTH = "image_width" + internal const val KEY_IMAGE_HEIGHT = "image_height" + internal const val KEY_ORIGINAL_WIDTH = "original_width" + internal const val KEY_ORIGINAL_HEIGHT = "original_height" + + internal const val KEY_OUTPUT_SHM = "output_shm" + internal const val KEY_DECODED_WIDTH = "decoded_width" + internal const val KEY_DECODED_HEIGHT = "decoded_height" + internal const val KEY_DECODED_BYTE_COUNT = "decoded_byte_count" + internal const val KEY_DECODED_CONFIG = "decoded_config" + + internal const val KEY_ERROR = "error" + internal const val KEY_ERROR_MESSAGE = "error_message" + + private const val ARGB_BYTES_PER_PIXEL = 4 + private const val INITIAL_INPUT_CAPACITY_BYTES = 64 * 1024 + private const val INPUT_BUFFER_BYTES = 64 * 1024 + private const val JXL_MIME_TYPE = "image/jxl" + private const val MAX_IMAGE_PIXELS = 1_000_000_000L + private const val MAX_SVG_BYTES = 16L * 1024L * 1024L + private const val MIN_DECODED_BITMAP_BYTES = 4L * 1024L * 1024L + private const val MAX_DECODED_BITMAP_BYTES = IsolatedImageDecoder.MAX_DECODED_BITMAP_BYTES + + private val HEIF_MIME_TYPES = setOf( + "image/heif", + "image/heic", + "image/heif-sequence", + "image/heic-sequence", + "image/avif", + "image/avis", + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecodeResult.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecodeResult.kt new file mode 100644 index 0000000000..d7df595657 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecodeResult.kt @@ -0,0 +1,9 @@ +package com.dot.gallery.core.sandbox + +import android.graphics.Bitmap +import android.util.Size + +internal data class IsolatedImageDecodeResult( + val bitmap: Bitmap, + val originalSize: Size, +) diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecoder.kt new file mode 100644 index 0000000000..ee39045563 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedImageDecoder.kt @@ -0,0 +1,349 @@ +package com.dot.gallery.core.sandbox + +import android.graphics.Bitmap +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.ParcelFileDescriptor +import android.os.SharedMemory +import android.util.Log +import android.util.Size as AndroidSize +import androidx.core.graphics.createBitmap +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_DECODED_BYTE_COUNT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_DECODED_CONFIG +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_DECODED_HEIGHT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_DECODED_WIDTH +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ERROR +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ERROR_MESSAGE +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_IMAGE_HEIGHT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_IMAGE_WIDTH +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_INPUT_PFD +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_MAX_DECODED_BYTES +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_MIME_TYPE +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ORIGINAL_HEIGHT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ORIGINAL_WIDTH +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_OUTPUT_SHM +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_TARGET_HEIGHT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_TARGET_WIDTH +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.MSG_DECODE +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.MSG_GET_SIZE +import com.dot.gallery.injection.qualifier.IoDispatcher +import java.io.InputStream +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.math.min +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +@Singleton +internal class IsolatedImageDecoder @Inject constructor( + private val connection: IsolatedDecoderConnection, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + private val requestMutex = Mutex() + + fun unbind() { + connection.unbind() + } + + suspend fun getSize(inputStream: InputStream, mimeType: String): AndroidSize? { + return requestMutex.withLock { + val resultBundle = request( + inputStream = inputStream, + mimeType = mimeType, + messageType = MSG_GET_SIZE, + targetWidth = 0, + targetHeight = 0, + ) ?: return@withLock null + + val width = resultBundle.getInt(KEY_IMAGE_WIDTH, 0) + val height = resultBundle.getInt(KEY_IMAGE_HEIGHT, 0) + when { + width <= 0 || height <= 0 -> null + else -> AndroidSize(width, height) + } + } + } + + suspend fun decode( + inputStream: InputStream, + mimeType: String, + targetWidth: Int = 0, + targetHeight: Int = 0, + ): IsolatedImageDecodeResult? { + return requestMutex.withLock { + val startNanoseconds = System.nanoTime() + val resultBundle = request( + inputStream = inputStream, + mimeType = mimeType, + messageType = MSG_DECODE, + targetWidth = targetWidth, + targetHeight = targetHeight, + ) ?: return@withLock null + + val result = readDecodeResult(bundle = resultBundle) ?: return@withLock null + val elapsedMilliseconds = (System.nanoTime() - startNanoseconds) / 1_000_000 + Log.d( + TAG, + "Decode took ${elapsedMilliseconds}ms " + + "(${result.bitmap.width}x${result.bitmap.height})", + ) + result + } + } + + private suspend fun request( + inputStream: InputStream, + mimeType: String, + messageType: Int, + targetWidth: Int, + targetHeight: Int, + ): Bundle? { + return withTimeoutOrNull(timeMillis = SERVICE_TIMEOUT_MILLIS) { + withContext(context = ioDispatcher) { + val messenger = connection.getMessenger() ?: return@withContext null + transferAndSend( + messenger = messenger, + inputStream = inputStream, + mimeType = mimeType, + messageType = messageType, + targetWidth = targetWidth, + targetHeight = targetHeight, + ) + } + } + } + + private suspend fun transferAndSend( + messenger: Messenger, + inputStream: InputStream, + mimeType: String, + messageType: Int, + targetWidth: Int, + targetHeight: Int, + ): Bundle? { + return coroutineScope { + val (readDescriptor, writeDescriptor) = ParcelFileDescriptor.createReliablePipe() + val writerJob = async(context = ioDispatcher) { + writeInput( + inputStream = inputStream, + writeDescriptor = writeDescriptor, + ) + } + + try { + val result = try { + sendAndReceive( + messenger = messenger, + what = messageType, + data = Bundle().apply { + putParcelable(KEY_INPUT_PFD, readDescriptor) + putString(KEY_MIME_TYPE, mimeType) + putInt(KEY_TARGET_WIDTH, targetWidth) + putInt(KEY_TARGET_HEIGHT, targetHeight) + putLong(KEY_MAX_DECODED_BYTES, decodedBitmapBudgetBytes()) + }, + ) + } finally { + closeQuietly(readDescriptor) + } + writerJob.await()?.let { failure -> + Log.w(TAG, "Input transfer failed: ${failure.message}") + return@coroutineScope null + } + result + } finally { + if (!writerJob.isCompleted) { + closeQuietly(inputStream) + } + closeQuietly(writeDescriptor) + cancelWriter(job = writerJob) + } + } + } + + private fun writeInput( + inputStream: InputStream, + writeDescriptor: ParcelFileDescriptor, + ): Exception? { + val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(writeDescriptor) + return try { + transferEncodedMedia( + inputStream = inputStream, + outputStream = outputStream, + ) + outputStream.flush() + outputStream.close() + null + } catch (failure: Exception) { + runCatching { + writeDescriptor.closeWithError(failure.message ?: "Input transfer failed") + } + runCatching { + outputStream.close() + } + failure + } + } + + private suspend fun sendAndReceive(messenger: Messenger, what: Int, data: Bundle): Bundle? { + return suspendCancellableCoroutine { continuation -> + val replyHandler = Handler(Looper.getMainLooper()) { message -> + val result = message.data + result.classLoader = SharedMemory::class.java.classLoader + if (continuation.isActive) { + continuation.resume(result) + } else { + closeOutputSharedMemory(bundle = result) + } + true + } + val message = Message.obtain().apply { + this.what = what + this.data = data + replyTo = Messenger(replyHandler) + } + + try { + messenger.send(message) + } catch (failure: Exception) { + Log.w(TAG, "Send failed: ${failure.message}") + if (continuation.isActive) { + continuation.resume(null) + } + } + }?.takeUnless { result -> + val hasError = result.getBoolean(KEY_ERROR, false) + if (hasError) { + val message = result.getString(KEY_ERROR_MESSAGE, "Unknown error") + Log.w(TAG, "Request failed: $message") + closeOutputSharedMemory(bundle = result) + } + hasError + } + } + + internal fun readDecodeResult(bundle: Bundle): IsolatedImageDecodeResult? { + bundle.classLoader = SharedMemory::class.java.classLoader + val outputSharedMemory = bundle + .getParcelable(KEY_OUTPUT_SHM, SharedMemory::class.java) + ?: return null + + val width = bundle.getInt(KEY_DECODED_WIDTH, 0) + val height = bundle.getInt(KEY_DECODED_HEIGHT, 0) + val originalWidth = bundle.getInt(KEY_ORIGINAL_WIDTH, 0) + val originalHeight = bundle.getInt(KEY_ORIGINAL_HEIGHT, 0) + val byteCount = bundle.getInt(KEY_DECODED_BYTE_COUNT, 0) + val configName = bundle.getString(KEY_DECODED_CONFIG, Bitmap.Config.ARGB_8888.name) + val config = runCatching { + Bitmap.Config.valueOf(configName) + }.getOrNull() + + val validResult = isValidDecodeResult( + width = width, + height = height, + originalWidth = originalWidth, + originalHeight = originalHeight, + byteCount = byteCount, + config = config, + ) + if (!validResult) { + outputSharedMemory.close() + return null + } + + return try { + val bitmap = createBitmap( + width = width, + height = height, + config = requireNotNull(config), + ) + val outputBuffer = outputSharedMemory.mapReadOnly() + try { + bitmap.copyPixelsFromBuffer(outputBuffer) + } finally { + SharedMemory.unmap(outputBuffer) + } + IsolatedImageDecodeResult( + bitmap = bitmap, + originalSize = AndroidSize(originalWidth, originalHeight), + ) + } catch (_: OutOfMemoryError) { + Log.w(TAG, "Decoded bitmap exceeds available heap") + null + } catch (failure: Exception) { + Log.w(TAG, "Failed to reconstruct bitmap: ${failure.message}") + null + } finally { + outputSharedMemory.close() + } + } + + private fun isValidDecodeResult( + width: Int, + height: Int, + originalWidth: Int, + originalHeight: Int, + byteCount: Int, + config: Bitmap.Config?, + ): Boolean { + if ( + width <= 0 || height <= 0 || + originalWidth <= 0 || originalHeight <= 0 || + byteCount <= 0 || config == null || config == Bitmap.Config.HARDWARE + ) { + return false + } + + val expectedByteCount = width.toLong() * height.toLong() * bytesPerPixel(config).toLong() + val budget = decodedBitmapBudgetBytes() + return expectedByteCount == byteCount.toLong() && expectedByteCount <= budget + } + + private fun bytesPerPixel(config: Bitmap.Config): Int { + return when (config) { + Bitmap.Config.ALPHA_8 -> 1 + Bitmap.Config.RGB_565, Bitmap.Config.ARGB_4444 -> 2 + Bitmap.Config.RGBA_F16 -> 8 + else -> 4 + } + } + + private fun decodedBitmapBudgetBytes(): Long { + return min(MAX_DECODED_BITMAP_BYTES, Runtime.getRuntime().maxMemory() / HEAP_BUDGET_DIVISOR) + } + + private fun closeOutputSharedMemory(bundle: Bundle) { + bundle.classLoader = SharedMemory::class.java.classLoader + bundle.getParcelable(KEY_OUTPUT_SHM, SharedMemory::class.java)?.close() + } + + private fun closeQuietly(closeable: AutoCloseable) { + runCatching { + closeable.close() + } + } + + private suspend fun cancelWriter(job: Job) { + job.cancel() + job.join() + } + + companion object { + internal const val MAX_DECODED_BITMAP_BYTES = 96L * 1024L * 1024L + + private const val TAG = "IsolatedImageDecoder" + private const val HEAP_BUDGET_DIVISOR = 4L + private const val SERVICE_TIMEOUT_MILLIS = 30_000L + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataParser.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataParser.kt index c356ae785c..db725a42a3 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataParser.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataParser.kt @@ -29,13 +29,15 @@ import com.dot.gallery.feature_node.presentation.exif.MetadataDirectory import com.dot.gallery.feature_node.presentation.exif.MetadataTag import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.printWarning +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.resume /** * Client for [IsolatedMetadataService]. @@ -99,6 +101,201 @@ class IsolatedMetadataParser(private val context: Context) { } } + // ── Per-file isolation (API 29+) ────────────────────────────────────── + + private val perFileExecutor = Executors.newSingleThreadExecutor() + + /** + * Bind a fresh isolated instance for a single media item. + * Call [unbindPerFile] after the parse operation completes. + */ + suspend fun bindPerFile(mediaId: Long): PerFileConnection? { + val instanceName = "metadata_$mediaId" + val intent = Intent(context, IsolatedMetadataService::class.java) + var perFileMessenger: Messenger? = null + + val perFileConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + perFileMessenger = Messenger(binder) + printDebug("IsolatedMetadataParser: per-file service connected ($instanceName)") + } + + override fun onServiceDisconnected(name: ComponentName?) { + perFileMessenger = null + printWarning("IsolatedMetadataParser: per-file service disconnected ($instanceName)") + } + } + + val bindResult = withContext(Dispatchers.Main) { + runCatching { + context.bindIsolatedService( + intent, + Context.BIND_AUTO_CREATE, + instanceName, + perFileExecutor, + perFileConnection, + ) + }.getOrElse { exception -> + printWarning("IsolatedMetadataParser: bindIsolatedService failed: ${exception.message}") + false + } + } + + if (!bindResult) { + return null + } + + var attempts = 0 + while (perFileMessenger == null && attempts < 50) { + kotlinx.coroutines.delay(100) + attempts++ + } + + val messenger = perFileMessenger + if (messenger == null) { + printWarning("IsolatedMetadataParser: per-file bind timed out ($instanceName)") + runCatching { + context.unbindService(perFileConnection) + } + return null + } + + return PerFileConnection( + serviceConnection = perFileConnection, + messenger = messenger, + instanceName = instanceName, + ) + } + + fun unbindPerFile(connection: PerFileConnection) { + runCatching { + context.unbindService(connection.serviceConnection) + } + printDebug("IsolatedMetadataParser: per-file service unbound (${connection.instanceName})") + } + + // ── Per-file public API ─────────────────────────────────────────────── + + /** + * Parse image metadata using a per-file isolated instance. + * Falls back to the shared isolated instance if the per-file bind fails. + */ + suspend fun parseImageMetadataPerFile( + uri: Uri, + label: String, + mediaId: Long, + ): Bundle? { + return withContext(Dispatchers.IO) { + val startNs = System.nanoTime() + val connection = bindPerFile(mediaId) + if (connection == null) { + printWarning("IsolatedMetadataParser: per-file bind failed, falling back to shared") + return@withContext parseImageMetadata(uri, label) + } + try { + val pfd = runCatching { + context.contentResolver.openFileDescriptor(uri, "r") + }.getOrNull() ?: return@withContext null + + val result = sendAndReceive( + messenger = connection.messenger, + what = MSG_PARSE_IMAGE, + data = Bundle().apply { + putParcelable(KEY_PFD, pfd) + putString(KEY_LABEL, label) + }, + ) + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + printDebug( + "IsolatedMetadataParser: image parse took ${elapsedMs}ms " + + "(per-file: ${connection.instanceName})" + ) + result + } finally { + unbindPerFile(connection) + } + } + } + + /** + * Parse video metadata using a per-file isolated instance. + * Falls back to the shared isolated instance if the per-file bind fails. + */ + suspend fun parseVideoMetadataPerFile(uri: Uri, mediaId: Long): Bundle? { + return withContext(Dispatchers.IO) { + val startNs = System.nanoTime() + val connection = bindPerFile(mediaId) + if (connection == null) { + printWarning("IsolatedMetadataParser: per-file bind failed, falling back to shared") + return@withContext parseVideoMetadata(uri) + } + try { + val pfd = runCatching { + context.contentResolver.openFileDescriptor(uri, "r") + }.getOrNull() ?: return@withContext null + + val result = sendAndReceive( + messenger = connection.messenger, + what = MSG_PARSE_VIDEO, + data = Bundle().apply { + putParcelable(KEY_PFD, pfd) + }, + ) + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + printDebug( + "IsolatedMetadataParser: video parse took ${elapsedMs}ms " + + "(per-file: ${connection.instanceName})" + ) + result + } finally { + unbindPerFile(connection) + } + } + } + + /** + * Parse raw metadata using a per-file isolated instance. + * Falls back to the shared isolated instance if the per-file bind fails. + */ + suspend fun parseRawMetadataPerFile( + uri: Uri, + isVideo: Boolean, + mediaId: Long, + ): List { + return withContext(Dispatchers.IO) { + val startNs = System.nanoTime() + val connection = bindPerFile(mediaId) + if (connection == null) { + printWarning("IsolatedMetadataParser: per-file bind failed, falling back to shared") + return@withContext parseRawMetadata(uri, isVideo) + } + try { + val pfd = runCatching { + context.contentResolver.openFileDescriptor(uri, "r") + }.getOrNull() ?: return@withContext emptyList() + + val bundle = sendAndReceive( + messenger = connection.messenger, + what = MSG_PARSE_RAW_METADATA, + data = Bundle().apply { + putParcelable(KEY_PFD, pfd) + putBoolean(KEY_IS_VIDEO, isVideo) + }, + ) ?: return@withContext emptyList() + + val result = unbundleRawMetadata(bundle) + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + printDebug( + "IsolatedMetadataParser: raw metadata parse took ${elapsedMs}ms " + + "(per-file: ${connection.instanceName})" + ) + result + } finally { + unbindPerFile(connection) + } + } + } + // ── Public API ──────────────────────────────────────────────────────── /** @@ -171,9 +368,17 @@ class IsolatedMetadataParser(private val context: Context) { // ── IPC internals ───────────────────────────────────────────────────── private suspend fun sendAndReceive(what: Int, data: Bundle): Bundle? { - val messenger = serviceMessenger ?: return null + val messenger = serviceMessenger + if (messenger == null) { + closeRequestFileDescriptor(data = data) + return null + } + + return sendAndReceive(messenger, what, data) + } - return withTimeoutOrNull(SERVICE_TIMEOUT_MS) { + private suspend fun sendAndReceive(messenger: Messenger, what: Int, data: Bundle): Bundle? { + return withTimeoutOrNull(serviceTimeout) { suspendCancellableCoroutine { cont -> val replyHandler = Handler(Looper.getMainLooper()) { msg -> val result = msg.data @@ -198,9 +403,13 @@ class IsolatedMetadataParser(private val context: Context) { try { messenger.send(msg) - } catch (e: Exception) { - printWarning("IsolatedMetadataParser: send failed: ${e.message}") - if (cont.isActive) cont.resume(null) + } catch (exception: Exception) { + printWarning("IsolatedMetadataParser: send failed: ${exception.message}") + if (cont.isActive) { + cont.resume(null) + } + } finally { + closeRequestFileDescriptor(data = data) } cont.invokeOnCancellation { @@ -210,6 +419,11 @@ class IsolatedMetadataParser(private val context: Context) { } } + private fun closeRequestFileDescriptor(data: Bundle) { + data.classLoader = ParcelFileDescriptor::class.java.classLoader + data.getParcelable(KEY_PFD, ParcelFileDescriptor::class.java)?.close() + } + private fun unbundleRawMetadata(bundle: Bundle): List { val dirNames = bundle.getStringArrayList(KEY_RAW_DIR_NAMES) ?: return emptyList() return dirNames.mapIndexedNotNull { index, name -> @@ -224,6 +438,6 @@ class IsolatedMetadataParser(private val context: Context) { } companion object { - private const val SERVICE_TIMEOUT_MS = 30_000L + private val serviceTimeout = 30_000.milliseconds } } diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataService.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataService.kt index 63e32fa2d3..54d6c655b2 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataService.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/IsolatedMetadataService.kt @@ -15,6 +15,9 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.os.ParcelFileDescriptor +import com.dot.gallery.core.sandbox.IsolatedMetadataService.Companion.MSG_PARSE_IMAGE +import com.dot.gallery.core.sandbox.IsolatedMetadataService.Companion.MSG_PARSE_RAW_METADATA +import com.dot.gallery.core.sandbox.IsolatedMetadataService.Companion.MSG_PARSE_VIDEO import com.drew.imaging.ImageMetadataReader import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifSubIFDDirectory @@ -78,10 +81,11 @@ class IsolatedMetadataService : Service() { // ── Image EXIF/XMP parsing (metadata-extractor) ─────────────────────── - @Suppress("DEPRECATION") private fun parseImageMetadata(input: Bundle): Bundle { - val pfd = input.getParcelable(KEY_PFD) + val pfd = input + .getParcelable(KEY_PFD, ParcelFileDescriptor::class.java) ?: return Bundle().apply { putBoolean(KEY_ERROR, true) } + val label = input.getString(KEY_LABEL, "") return pfd.use { fd -> @@ -217,9 +221,9 @@ class IsolatedMetadataService : Service() { // ── Video metadata parsing (MediaMetadataRetriever) ─────────────────── - @Suppress("DEPRECATION") private fun parseVideoMetadata(input: Bundle): Bundle { - val pfd = input.getParcelable(KEY_PFD) + val pfd = input + .getParcelable(KEY_PFD, ParcelFileDescriptor::class.java) ?: return Bundle().apply { putBoolean(KEY_ERROR, true) } return pfd.use { fd -> @@ -256,10 +260,11 @@ class IsolatedMetadataService : Service() { // ── Raw metadata for the "View all metadata" screen ─────────────────── - @Suppress("DEPRECATION") private fun parseRawMetadata(input: Bundle): Bundle { - val pfd = input.getParcelable(KEY_PFD) + val pfd = input + .getParcelable(KEY_PFD, ParcelFileDescriptor::class.java) ?: return Bundle().apply { putBoolean(KEY_ERROR, true) } + val isVideo = input.getBoolean(KEY_IS_VIDEO, false) return pfd.use { fd -> diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoder.kt new file mode 100644 index 0000000000..dff5d66f96 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoder.kt @@ -0,0 +1,245 @@ +package com.dot.gallery.core.sandbox + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.ParcelFileDescriptor +import android.os.SharedMemory +import android.util.Log +import android.util.Size +import androidx.core.graphics.createBitmap +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ERROR +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_ERROR_MESSAGE +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_INPUT_PFD +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_IS_VIDEO +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_MAX_DECODED_BYTES +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_MIME_TYPE +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_OUTPUT_SHM +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_TARGET_HEIGHT +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_TARGET_WIDTH +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.KEY_USE_VIDEO_THUMBNAIL +import com.dot.gallery.core.sandbox.IsolatedDecoderService.Companion.MSG_DECODE_PREVIEW +import com.dot.gallery.core.util.runCancellableContentProviderOperation +import com.dot.gallery.injection.qualifier.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +@Singleton +internal class MediaPreviewDecoder @Inject constructor( + private val connection: MediaAnalysisDecoderConnection, + private val contentResolver: ContentResolver, + private val imageDecoder: IsolatedImageDecoder, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + private val requestMutex = Mutex() + + suspend fun decode(uri: Uri, mimeType: String?, isVideo: Boolean): Bitmap? { + return requestMutex.withLock { + var requestCompleted = false + val result = withTimeoutOrNull(timeMillis = SERVICE_TIMEOUT_MILLIS) { + val decodedBitmap = withContext(context = ioDispatcher) { + decodeLocked( + uri = uri, + mimeType = mimeType, + isVideo = isVideo, + ) + } + requestCompleted = true + decodedBitmap + } + if (!requestCompleted) { + Log.w(TAG, "Media preview decoding timed out") + connection.unbind() + } + result + } + } + + private suspend fun decodeLocked(uri: Uri, mimeType: String?, isVideo: Boolean): Bitmap? { + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + return null + } + + val response = requestIsolatedPreview( + uri = uri, + mimeType = mimeType, + isVideo = isVideo, + ) ?: return null + + return when { + response.getBoolean(KEY_USE_VIDEO_THUMBNAIL, false) -> { + loadVideoThumbnail(uri = uri) + } + + response.getBoolean(KEY_ERROR, false) -> { + Log.w( + TAG, + "Isolated media preview failed: ${response.getString(KEY_ERROR_MESSAGE)}", + ) + null + } + + else -> { + imageDecoder.readDecodeResult(bundle = response)?.bitmap + } + } + } + + private suspend fun requestIsolatedPreview( + uri: Uri, + mimeType: String?, + isVideo: Boolean, + ): Bundle? { + val descriptor = try { + contentResolver.openFileDescriptorCancellable(uri = uri, mode = "r") + } catch (failure: Exception) { + currentCoroutineContext().ensureActive() + Log.w(TAG, "Unable to open media preview descriptor", failure) + null + } ?: return null + + return descriptor.use { inputDescriptor -> + val encodedSize = inputDescriptor.statSize + if (encodedSize == 0L) { + return@use null + } + val messenger = connection.getMessenger() ?: return@use null + sendAndReceive( + messenger = messenger, + data = Bundle().apply { + putParcelable(KEY_INPUT_PFD, inputDescriptor) + putString(KEY_MIME_TYPE, mimeType ?: contentResolver.getType(uri).orEmpty()) + putBoolean(KEY_IS_VIDEO, isVideo) + putInt(KEY_TARGET_WIDTH, PREVIEW_SIZE_PIXELS) + putInt(KEY_TARGET_HEIGHT, PREVIEW_SIZE_PIXELS) + putLong(KEY_MAX_DECODED_BYTES, PREVIEW_DECODED_BYTES) + }, + ) + } + } + + private suspend fun loadVideoThumbnail(uri: Uri): Bitmap? { + return try { + val thumbnail = contentResolver.loadThumbnailCancellable( + uri = uri, + size = Size(PREVIEW_SIZE_PIXELS, PREVIEW_SIZE_PIXELS), + ) + normalizeThumbnail(bitmap = thumbnail) + } catch (failure: Exception) { + currentCoroutineContext().ensureActive() + Log.w(TAG, "Unable to load system video thumbnail", failure) + null + } + } + + private fun normalizeThumbnail(bitmap: Bitmap): Bitmap { + val sourceAspectRatio = bitmap.width.toDouble() / bitmap.height.toDouble() + val targetAspectRatio = 1.0 + val sourceRect = when { + sourceAspectRatio > targetAspectRatio -> { + val cropWidth = bitmap.height + val left = (bitmap.width - cropWidth) / 2 + Rect(left, 0, left + cropWidth, bitmap.height) + } + + else -> { + val cropHeight = bitmap.width + val top = (bitmap.height - cropHeight) / 2 + Rect(0, top, bitmap.width, top + cropHeight) + } + } + return try { + createBitmap( + width = PREVIEW_SIZE_PIXELS, + height = PREVIEW_SIZE_PIXELS, + config = Bitmap.Config.ARGB_8888, + ).apply { + Canvas(this).drawBitmap( + bitmap, + sourceRect, + Rect(0, 0, PREVIEW_SIZE_PIXELS, PREVIEW_SIZE_PIXELS), + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG), + ) + } + } finally { + bitmap.recycle() + } + } + + private suspend fun sendAndReceive(messenger: Messenger, data: Bundle): Bundle? { + return suspendCancellableCoroutine { continuation -> + val replyHandler = Handler(Looper.getMainLooper()) { message -> + val result = message.data + result.classLoader = SharedMemory::class.java.classLoader + if (continuation.isActive) { + continuation.resume(result) + } else { + closeOutputSharedMemory(bundle = result) + } + true + } + val request = Message.obtain().apply { + what = MSG_DECODE_PREVIEW + this.data = data + replyTo = Messenger(replyHandler) + } + runCatching { + messenger.send(request) + }.onFailure { failure -> + Log.w(TAG, "Unable to request isolated media preview", failure) + if (continuation.isActive) { + continuation.resume(null) + } + } + } + } + + private fun closeOutputSharedMemory(bundle: Bundle) { + bundle.classLoader = SharedMemory::class.java.classLoader + bundle.getParcelable(KEY_OUTPUT_SHM, SharedMemory::class.java)?.close() + } + + companion object { + private const val PREVIEW_SIZE_PIXELS = 224 + private const val PREVIEW_DECODED_BYTES = + PREVIEW_SIZE_PIXELS.toLong() * PREVIEW_SIZE_PIXELS.toLong() * 4L + private const val SERVICE_TIMEOUT_MILLIS = 30_000L + private const val TAG = "MediaPreviewDecoder" + } +} + +internal suspend fun ContentResolver.loadThumbnailCancellable(uri: Uri, size: Size): Bitmap { + return runCancellableContentProviderOperation( + disposeResult = { bitmap -> bitmap.recycle() }, + ) { cancellationSignal -> + loadThumbnail(uri, size, cancellationSignal) + } +} + +internal suspend fun ContentResolver.openFileDescriptorCancellable( + uri: Uri, + mode: String, +): ParcelFileDescriptor? { + return runCancellableContentProviderOperation( + disposeResult = { descriptor -> descriptor?.close() }, + ) { cancellationSignal -> + openFileDescriptor(uri, mode, cancellationSignal) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/PerFileConnection.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/PerFileConnection.kt new file mode 100644 index 0000000000..2167a8a667 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/PerFileConnection.kt @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.sandbox + +import android.content.ServiceConnection +import android.os.Messenger + +/** + * Holds a per-file isolated service connection. + * Each instance represents a unique isolated process bound via + * [android.content.Context.bindIsolatedService]. + */ +data class PerFileConnection( + val serviceConnection: ServiceConnection, + val messenger: Messenger, + val instanceName: String, +) diff --git a/app/src/main/kotlin/com/dot/gallery/core/sandbox/SandboxedDecoderHolder.kt b/app/src/main/kotlin/com/dot/gallery/core/sandbox/SandboxedDecoderHolder.kt new file mode 100644 index 0000000000..c2006161c9 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/sandbox/SandboxedDecoderHolder.kt @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.sandbox + +import android.annotation.SuppressLint +import android.content.Context + +/** + * Global access point for the forced-on isolated image decoder. + */ +@SuppressLint("StaticFieldLeak") +object SandboxedDecoderHolder { + + @Volatile + internal var decoder: IsolatedImageDecoder? = null + private set + + internal fun init(decoder: IsolatedImageDecoder) { + this.decoder = decoder + } + + fun isEnabled(@Suppress("UNUSED_PARAMETER") context: Context): Boolean { + return true + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/CancellableContentProviderOperation.kt b/app/src/main/kotlin/com/dot/gallery/core/util/CancellableContentProviderOperation.kt new file mode 100644 index 0000000000..067a25ed8c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/CancellableContentProviderOperation.kt @@ -0,0 +1,31 @@ +package com.dot.gallery.core.util + +import android.os.CancellationSignal +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +internal suspend fun runCancellableContentProviderOperation( + disposeResult: (Result) -> Unit, + operation: (CancellationSignal) -> Result, +): Result { + return suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { + cancellationSignal.cancel() + } + + val result = try { + operation(cancellationSignal) + } catch (exception: Throwable) { + if (continuation.isActive) { + continuation.resumeWithException(exception) + } + return@suspendCancellableCoroutine + } + + continuation.resume(result) { _, cancelledResult, _ -> + disposeResult(cancelledResult) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/MediaUtilsProvider.kt b/app/src/main/kotlin/com/dot/gallery/core/util/MediaUtilsProvider.kt index 1f20446e69..7ae1a34f2d 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/util/MediaUtilsProvider.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/util/MediaUtilsProvider.kt @@ -1,12 +1,11 @@ package com.dot.gallery.core.util +import android.graphics.drawable.ColorDrawable import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import com.dot.gallery.core.Settings -import android.graphics.drawable.ColorDrawable import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.integration.compose.placeholder @@ -20,6 +19,7 @@ import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaSelector +import com.dot.gallery.core.Settings import com.dot.gallery.core.presentation.components.LocalMediaImageRenderer import com.dot.gallery.core.presentation.components.MediaImageRenderer import com.dot.gallery.feature_node.domain.util.EventHandler diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt b/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt index 611e242813..7755c85a0e 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt @@ -2,7 +2,7 @@ package com.dot.gallery.core.util import android.content.Intent import android.provider.MediaStore -import com.dot.gallery.feature_node.domain.model.MediaType +import com.dot.gallery.feature_node.data.model.MediaType object PickerUtils { private const val MIME_TYPE_IMAGE_ANY = "image/*" diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/SizeLimitedInputStream.kt b/app/src/main/kotlin/com/dot/gallery/core/util/SizeLimitedInputStream.kt new file mode 100644 index 0000000000..6dc3056d87 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/SizeLimitedInputStream.kt @@ -0,0 +1,54 @@ +package com.dot.gallery.core.util + +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream + +internal const val MAX_ENCODED_MEDIA_BYTES = 128L * 1024L * 1024L + +internal class SizeLimitExceededException(maximumBytes: Long) : IOException( + "Input exceeds the $maximumBytes byte limit", +) + +internal class SizeLimitedInputStream( + inputStream: InputStream, + private val maximumBytes: Long, +) : FilterInputStream(inputStream) { + + private var consumedBytes = 0L + + init { + require(maximumBytes >= 0L) { "Maximum byte count must not be negative" } + } + + override fun read(): Int { + val value = super.read() + if (value != -1) { + recordBytes(byteCount = 1L) + } + return value + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val readBytes = super.read(buffer, offset, length) + if (readBytes > 0) { + recordBytes(byteCount = readBytes.toLong()) + } + return readBytes + } + + override fun skip(byteCount: Long): Long { + val skippedBytes = super.skip(byteCount) + if (skippedBytes > 0L) { + recordBytes(byteCount = skippedBytes) + } + return skippedBytes + } + + private fun recordBytes(byteCount: Long) { + if (byteCount > maximumBytes - consumedBytes) { + throw SizeLimitExceededException(maximumBytes = maximumBytes) + } + consumedBytes += byteCount + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/ext/BroadcastReceiverExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/ext/BroadcastReceiverExt.kt new file mode 100644 index 0000000000..0981797a18 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/BroadcastReceiverExt.kt @@ -0,0 +1,38 @@ +package com.dot.gallery.core.util.ext + +import android.content.BroadcastReceiver +import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Runs [block] off the main thread while keeping the broadcast alive via + * [BroadcastReceiver.goAsync], finishing it once [block] completes. + * + * A receiver has no lifecycle to scope work to, so the coroutine runs in a detached scope. + * Failures are logged rather than propagated: an uncaught throwable would otherwise reach the + * default handler and take down the process over background bookkeeping. + * + * [block] must stay well inside the broadcast timeout — it is not cancellable, since the work + * it wraps is blocking I/O with no suspension points for a timeout to act on. + * + * [tag] is passed in rather than derived from the class name, which R8 rewrites in release builds. + */ +internal fun BroadcastReceiver.goAsync( + tag: String, + dispatcher: CoroutineDispatcher, + block: suspend CoroutineScope.() -> Unit, +) { + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + dispatcher).launch { + try { + block() + } catch (exception: Exception) { + Log.w(tag, "Background broadcast work failed", exception) + } finally { + pendingResult.finish() + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt index a23d60d89b..e98bb13aa7 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt @@ -9,155 +9,208 @@ import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.database.ContentObserver +import android.database.Cursor import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.MediaScannerConnection import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.CancellationSignal import android.os.Environment import android.os.Handler import android.os.Looper +import android.os.OperationCanceledException import android.provider.MediaStore import androidx.exifinterface.media.ExifInterface -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.core.util.runCancellableContentProviderOperation +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.util.printWarning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.withContext + +private const val MILLIS_PER_SECOND = 1_000L +private const val QUERY_DEBOUNCE_MILLIS = 250L +private const val STEPPED_QUERY_LIMIT = 250 fun ContentResolver.querySteppedFlow( uri: Uri, projection: Array? = null, queryArgs: Bundle? = Bundle(), -) = callbackFlow { - // Each query will have its own cancellationSignal. - // Before running any new query the old cancellationSignal must be cancelled - // to ensure the currently running query gets interrupted so that we don't - // send data across the channel if we know we received a newer set of data. - var cancellationSignal = CancellationSignal() - // ContentObserver.onChange can be called concurrently so make sure - // access to the cancellationSignal is synchronized. - val mutex = Mutex() - val modifiedArgs = queryArgs?.deepCopy()?.apply { - putString(ContentResolver.QUERY_ARG_SQL_LIMIT, "250") - } +): Flow { + return observedQueryFlow( + uri = uri, + projection = projection, + queryArgs = queryArgs, + steppedInitialQuery = true, + ) +} - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - launch(Dispatchers.IO) { - mutex.withLock { - cancellationSignal.cancel() - cancellationSignal = CancellationSignal() - } - runCatching { - trySend(query(uri, projection, queryArgs, cancellationSignal)) - } +fun ContentResolver.queryFlow( + uri: Uri, + projection: Array? = null, + queryArgs: Bundle? = Bundle(), +): Flow { + return observedQueryFlow( + uri = uri, + projection = projection, + queryArgs = queryArgs, + steppedInitialQuery = false, + ) +} + +@OptIn(FlowPreview::class) +private fun ContentResolver.observedQueryFlow( + uri: Uri, + projection: Array?, + queryArgs: Bundle?, + steppedInitialQuery: Boolean, +): Flow { + return channelFlow { + val activeCancellationSignal = AtomicReference(null) + val invalidationGeneration = AtomicLong(0L) + val invalidations = Channel(capacity = Channel.CONFLATED) + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + val generation = invalidationGeneration.incrementAndGet() + activeCancellationSignal.getAndSet(null)?.cancel() + invalidations.trySend(generation) } } - } - - registerContentObserver(uri, true, observer) - // The first set of values must always be generated and cannot (shouldn't) be cancelled. - launch(Dispatchers.IO) { - runCatching { - val batchCursor = query(uri, projection, modifiedArgs, null) - val batchCount = batchCursor?.count ?: 0 - trySend(batchCursor) - // Only run the full query if the batch was actually limited - if (batchCount >= 250) { - trySend(query(uri, projection, queryArgs, null)) + registerContentObserver(uri, true, observer) + var isInitialQuery = true + try { + merge( + flowOf(0L), + invalidations.receiveAsFlow().debounce(QUERY_DEBOUNCE_MILLIS), + ).collectLatest { generation -> + val shouldUseSteppedQuery = isInitialQuery && steppedInitialQuery + isInitialQuery = false + runObservedQuery( + output = this, + uri = uri, + projection = projection, + queryArgs = queryArgs, + stepped = shouldUseSteppedQuery, + activeCancellationSignal = activeCancellationSignal, + invalidationGeneration = invalidationGeneration, + expectedGeneration = generation, + ) } + } finally { + unregisterContentObserver(observer) + activeCancellationSignal.getAndSet(null)?.cancel() + invalidations.close() } } +} - awaitClose { - // Stop receiving content changes. - unregisterContentObserver(observer) - // Cancel any possibly running query. - cancellationSignal.cancel() - } -}.conflate() - -fun ContentResolver.queryFlow( +private suspend fun ContentResolver.runObservedQuery( + output: ProducerScope, uri: Uri, - projection: Array? = null, - queryArgs: Bundle? = Bundle(), -) = callbackFlow { - // Each query will have its own cancellationSignal. - // Before running any new query the old cancellationSignal must be cancelled - // to ensure the currently running query gets interrupted so that we don't - // send data across the channel if we know we received a newer set of data. - var cancellationSignal = CancellationSignal() - // ContentObserver.onChange can be called concurrently so make sure - // access to the cancellationSignal is synchronized. - val mutex = Mutex() - - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - launch(Dispatchers.IO) { - mutex.withLock { - cancellationSignal.cancel() - cancellationSignal = CancellationSignal() + projection: Array?, + queryArgs: Bundle?, + stepped: Boolean, + activeCancellationSignal: AtomicReference, + invalidationGeneration: AtomicLong, + expectedGeneration: Long, +) { + try { + withContext(Dispatchers.IO) { + if (stepped) { + val limitedArgs = queryArgs?.deepCopy()?.apply { + putString(ContentResolver.QUERY_ARG_SQL_LIMIT, STEPPED_QUERY_LIMIT.toString()) } - runCatching { - trySend(query(uri, projection, queryArgs, cancellationSignal)) + val limitedCursor = queryWithCancellation( + uri = uri, + projection = projection, + queryArgs = limitedArgs, + activeCancellationSignal = activeCancellationSignal, + invalidationGeneration = invalidationGeneration, + expectedGeneration = expectedGeneration, + ) + val limitedCount = limitedCursor?.count ?: 0 + output.sendOwnedCursor(cursor = limitedCursor) + if (limitedCount < STEPPED_QUERY_LIMIT) { + return@withContext } } - } - } - registerContentObserver(uri, true, observer) - - // The first set of values must always be generated and cannot (shouldn't) be cancelled. - launch(Dispatchers.IO) { - runCatching { - trySend( - query(uri, projection, queryArgs, null) + val cursor = queryWithCancellation( + uri = uri, + projection = projection, + queryArgs = queryArgs, + activeCancellationSignal = activeCancellationSignal, + invalidationGeneration = invalidationGeneration, + expectedGeneration = expectedGeneration, ) + output.sendOwnedCursor(cursor = cursor) } + } catch (exception: CancellationException) { + throw exception + } catch (_: OperationCanceledException) { + // A newer observer event cancelled this obsolete query. + } catch (exception: Exception) { + printWarning("MediaStore query failed: ${exception.message.orEmpty()}") } +} - awaitClose { - // Stop receiving content changes. - unregisterContentObserver(observer) - // Cancel any possibly running query. - cancellationSignal.cancel() +private suspend fun ContentResolver.queryWithCancellation( + uri: Uri, + projection: Array?, + queryArgs: Bundle?, + activeCancellationSignal: AtomicReference, + invalidationGeneration: AtomicLong, + expectedGeneration: Long, +): Cursor? { + return runCancellableContentProviderOperation( + disposeResult = { cursor -> cursor?.close() }, + ) { cancellationSignal -> + activeCancellationSignal.set(cancellationSignal) + if (invalidationGeneration.get() != expectedGeneration) { + cancellationSignal.cancel() + } + try { + val cursor = query(uri, projection, queryArgs, cancellationSignal) + if (invalidationGeneration.get() != expectedGeneration) { + cursor?.close() + throw OperationCanceledException() + } + cursor + } finally { + activeCancellationSignal.compareAndSet(cancellationSignal, null) + } } -}.conflate() +} -suspend fun ContentResolver.overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG -): Boolean = withContext(Dispatchers.IO) { - runCatching { - update(uri, ContentValues(), null) - openOutputStream(uri)?.use { out -> - if (!bitmap.compress(format, 100, out)) throw IOException("Compression failed") - } ?: throw IOException("Stream open failed") - update( - uri, - ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, - null - ) > 0 - }.getOrElse { - throw it +private suspend fun ProducerScope.sendOwnedCursor( + cursor: Cursor?, +) { + try { + send(cursor) + } catch (exception: Throwable) { + cursor?.close() + throw exception } } @@ -302,18 +355,57 @@ suspend fun ContentResolver.saveVideoStream( private suspend fun ContentResolver.performInsertWrite( baseUri: Uri, values: ContentValues, - writeBlock: (OutputStream) -> Unit -): Uri? = withContext(Dispatchers.IO) { - var tmp: Uri? = null + writeBlock: (OutputStream) -> Unit, +): Uri? { + return withContext(Dispatchers.IO) { + var destinationUri: Uri? = null + try { + val pendingValues = ContentValues(values).apply { + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + destinationUri = insert(baseUri, pendingValues) + ?: throw IOException("Insert returned null") + + openOutputStream(destinationUri)?.use { outputStream -> + writeBlock(outputStream) + outputStream.flush() + } ?: throw IOException("Stream open failed") + + currentCoroutineContext().ensureActive() + withContext(NonCancellable) { + val publishedRows = update( + destinationUri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + put( + MediaStore.MediaColumns.DATE_MODIFIED, + System.currentTimeMillis() / MILLIS_PER_SECOND, + ) + }, + null, + null, + ) + if (publishedRows != 1) { + throw IOException("Unable to publish inserted media") + } + } + destinationUri + } catch (exception: CancellationException) { + destinationUri?.let { uri -> deleteUnpublishedMedia(uri = uri) } + throw exception + } catch (exception: Exception) { + destinationUri?.let { uri -> deleteUnpublishedMedia(uri = uri) } + printWarning(exception.message.orEmpty()) + null + } + } +} + +private fun ContentResolver.deleteUnpublishedMedia(uri: Uri) { runCatching { - insert(baseUri, values)?.also { uri -> - tmp = uri - openOutputStream(uri)?.use(writeBlock) - ?: throw IOException("Stream open failed") - } ?: throw IOException("Insert returned null") - }.getOrElse { - tmp?.let { delete(it, null, null) } - null + delete(uri, null, null) + }.onFailure { exception -> + printWarning("Unable to delete unpublished media: ${exception.message.orEmpty()}") } } @@ -323,7 +415,8 @@ suspend fun Context.renameMedia(media: T, newName: String): Boolean contentResolver.update( media.getUri(), ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, newName) }, - null + null, + null, ) > 0 }.onSuccess { MediaScannerConnection.scanFile( @@ -338,18 +431,33 @@ suspend fun Context.renameMedia(media: T, newName: String): Boolean suspend fun Context.updateMedia( media: T, - contentValues: ContentValues -): Boolean = withContext(Dispatchers.IO) { - runCatching { - contentResolver.update(media.getUri(), contentValues, null) > 0 - }.onSuccess { - MediaScannerConnection.scanFile( - this@updateMedia, arrayOf(media.path.removeSuffix(media.label)), - arrayOf(media.mimeType), null - ) - }.getOrElse { - printWarning(it.message.toString()) - false + contentValues: ContentValues, +): Boolean { + return withContext(Dispatchers.IO) { + runCatching { + contentResolver.update(media.getUri(), contentValues, null, null) > 0 + }.onSuccess { updated -> + if (updated) { + // If RELATIVE_PATH changed, scan the new file location. + val newRelativePath = + contentValues.getAsString(MediaStore.MediaColumns.RELATIVE_PATH) + val scanPath = if (newRelativePath != null) { + val volumePrefix = media.path.substringBeforeLast("/") + .removeSuffix(media.relativePath.removeSuffix("/")) + .trimEnd('/') + "${volumePrefix}/${newRelativePath.trimEnd('/')}/${media.label}" + } else { + media.path + } + MediaScannerConnection.scanFile( + this@updateMedia, arrayOf(scanPath), + arrayOf(media.mimeType), null + ) + } + }.getOrElse { + printWarning(it.message.toString()) + false + } } } @@ -375,166 +483,3 @@ suspend fun Context.updateMediaExif( false } } - - -/** - * Overwrite an existing media row (image) with a new bitmap. - * Returns true on success, false on failure (never throws unless coroutine cancelled). - */ -suspend fun ContentResolver.overrideImage( - uri: Uri, - bitmap: Bitmap, - mimeType: String? = null, - format: Bitmap.CompressFormat? = null, - quality: Int = 95, - keepExif: Boolean = true, - recycleSource: Boolean = false, - sizeLimitBytes: Long? = null, - onSizeLimitExceeded: ((Long) -> Unit)? = null -): Boolean = withContext(Dispatchers.IO) { - runCatching { - // 1. Resolve mime + format - val resolvedMime = mimeType - ?: getType(uri) - ?: "image/jpeg" - - val compressFormat = format ?: inferCompressFormat(resolvedMime) - - // 2. Capture EXIF (only if JPEG + keepExif) - val exifData: MutableMap? = - if (keepExif && resolvedMime.contains("jpeg", true)) { - try { - openInputStream(uri)?.use { input -> - val exif = ExifInterface(input) - copyExifTags(exif) - } - } catch (_: Exception) { null } - } else null - - // 3. Mark pending (scoped storage) to reduce race (best effort) - val canPending = Build.VERSION.SDK_INT >= 29 - if (canPending) { - runCatching { - update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 1) - }, null, null) - } - } - - // 4. Encode into memory first (atomic style) so we fail early - val encoded = ByteArrayOutputStream().use { bos -> - if (!bitmap.compress(compressFormat, quality.coerceIn(0,100), bos)) - error("Bitmap.compress returned false") - bos.toByteArray() - } - - // 5. Size limit check - sizeLimitBytes?.let { limit -> - if (encoded.size.toLong() > limit) { - onSizeLimitExceeded?.invoke(encoded.size.toLong()) - if (canPending) clearPendingQuiet(uri) - return@runCatching false - } - } - - // 6. Write (truncate + replace) - (openOutputStream(uri, "rwt") // "rwt" truncates if supported - ?: openOutputStream(uri) // fallback - ?: error("Failed to open output stream")) - .use { out -> - out.write(encoded) - out.flush() - if (out is FileOutputStream) { - try { out.fd.sync() } catch (_: Exception) {} - } - } - - // 7. Restore EXIF (requires rewrite for JPEG) - if (exifData != null && compressFormat == Bitmap.CompressFormat.JPEG) { - try { - // Re-open and rewrite selected tags - openFileDescriptor(uri, "rw")?.use { pfd -> - FileInputStream(pfd.fileDescriptor).use { fis -> - // Need temp file because ExifInterface rewrite requires random access - val tmp = File.createTempFile("exif_tmp", ".jpg") - tmp.outputStream().use { it.write(encoded) } - val exif = ExifInterface(tmp.absolutePath) - exifData.forEach { (k, v) -> exif.setAttribute(k, v) } - // After physical rotation, orientation must be normal - exif.setAttribute( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL.toString() - ) - exif.saveAttributes() - // Write back - FileInputStream(tmp).use { updated -> - openOutputStream(uri, "rwt")?.use { finalOut -> - updated.copyTo(finalOut) - finalOut.flush() - } - } - tmp.delete() - } - } - } catch (_: Exception) { - // Silent; keep at least the pixels - } - } - - if (recycleSource) runCatching { bitmap.recycle() } - - // 8. Clear pending - if (canPending) clearPendingQuiet(uri) - - true - }.getOrElse { - clearPendingQuiet(uri) - false - } -} - -private fun ContentResolver.clearPendingQuiet(uri: Uri) { - runCatching { - update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }, null, null) - } -} - -private fun inferCompressFormat(mime: String): Bitmap.CompressFormat = - when { - mime.contains("png", true) -> Bitmap.CompressFormat.PNG - mime.contains("webp", true) -> { - @Suppress("DEPRECATION") - Bitmap.CompressFormat.WEBP - } - mime.contains("jpeg", true) || mime.contains("jpg", true) -> Bitmap.CompressFormat.JPEG - else -> Bitmap.CompressFormat.PNG - } - -private val EXIF_PASSTHROUGH_TAGS = arrayOf( - ExifInterface.TAG_DATETIME_ORIGINAL, - ExifInterface.TAG_DATETIME, - ExifInterface.TAG_MAKE, - ExifInterface.TAG_MODEL, - ExifInterface.TAG_F_NUMBER, - ExifInterface.TAG_FOCAL_LENGTH, - ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, - ExifInterface.TAG_EXPOSURE_TIME, - ExifInterface.TAG_WHITE_BALANCE, - ExifInterface.TAG_GPS_LATITUDE, - ExifInterface.TAG_GPS_LONGITUDE, - ExifInterface.TAG_GPS_LATITUDE_REF, - ExifInterface.TAG_GPS_LONGITUDE_REF, - ExifInterface.TAG_GPS_ALTITUDE, - ExifInterface.TAG_GPS_ALTITUDE_REF, - ExifInterface.TAG_GPS_TIMESTAMP, - ExifInterface.TAG_GPS_DATESTAMP -) - -private fun copyExifTags(exif: ExifInterface): MutableMap = - buildMap { - for (tag in EXIF_PASSTHROUGH_TAGS) { - exif.getAttribute(tag)?.let { put(tag, it) } - } - }.toMutableMap() diff --git a/app/src/main/kotlin/com/dot/gallery/core/video/DecryptionProgressRegistry.kt b/app/src/main/kotlin/com/dot/gallery/core/video/DecryptionProgressRegistry.kt deleted file mode 100644 index 8cc890aa8e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/video/DecryptionProgressRegistry.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.dot.gallery.core.video - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.ConcurrentHashMap - -/** - * Tracks decryption progress per media id/path during session. - * For initial implementation (full decrypt upfront) we emit 0 -> 100 instantly; future - * streaming block decrypt will update progressively. - */ -object DecryptionProgressRegistry { - private val progressMap = ConcurrentHashMap>() - - fun flowFor(key: String): StateFlow = progressMap.getOrPut(key) { MutableStateFlow(0) } - - fun update(key: String, percent: Int) { - progressMap[key]?.value = percent.coerceIn(0, 100) - } - - fun reset(key: String) { progressMap[key]?.value = 0 } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/video/StreamingEncryptedDataSource.kt b/app/src/main/kotlin/com/dot/gallery/core/video/StreamingEncryptedDataSource.kt deleted file mode 100644 index 878ad93d3e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/video/StreamingEncryptedDataSource.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.dot.gallery.core.video - -import androidx.media3.common.C -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.BaseDataSource -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSpec -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import kotlinx.coroutines.runBlocking -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.min - -/** - * StreamingEncryptedDataSource - * --------------------------------- - * On-demand block decrypting DataSource so ExoPlayer can start playback without waiting for - * full file decryption. For now, we rely on the underlying encrypted file having a clear - * container header (ftyp + moov at head). If moov is tail-based, initial seeks may still stall - * until decrypted. Future improvement: relocate moov or pre-decrypt header region. - * - * Encryption format assumption: Entire original media bytes were stored inside an - * EncryptedMedia(bytes=...) blob. Here we decrypt that blob lazily by memory-mapping/decrypting - * in fixed blocks. To align with existing decryptKotlin() which returns the full byte[] we - * currently fall back to a one-time full decrypt on first open but expose incremental read. - * - * To evolve to true streaming: replace the 'fullBytes' materialization with chunked decryption - * that decrypts only requested blocks (e.g. AES-CTR) and caches them in an LRU. - */ -@UnstableApi -class StreamingEncryptedDataSource( - private val encryptedFile: File, - private val keychainHolder: KeychainHolder, - private val blockSize: Int = 256 * 1024 // 256 KiB blocks for potential future partial decrypt -) : BaseDataSource(/* isNetwork= */ false), DataSource { - - private var opened = false - private var uri = android.net.Uri.fromFile(encryptedFile) - private var fullBytes: ByteArray? = null - private var readPosition: Long = 0 - private var bytesRemaining: Long = C.LENGTH_UNSET.toLong() - private val closed = AtomicBoolean(false) - - override fun open(dataSpec: DataSpec): Long { - if (opened) return bytesRemaining - transferInitializing(dataSpec) - // For MVP: decrypt entire payload once using existing util (blocking). In future this - // can be replaced by incremental block decrypt using key material. - val bytes = runBlocking { - with(keychainHolder) { encryptedFile.decryptKotlin().bytes } - } - fullBytes = bytes - readPosition = dataSpec.position - bytesRemaining = bytes.size - readPosition - if (readPosition >= bytes.size) { - bytesRemaining = 0 - } - opened = true - transferStarted(dataSpec) - return bytesRemaining - } - - override fun getUri(): android.net.Uri? = uri - - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { - val localBytes = fullBytes ?: return C.RESULT_END_OF_INPUT - if (bytesRemaining <= 0) return C.RESULT_END_OF_INPUT - val toRead = min(length.toLong(), bytesRemaining).toInt() - System.arraycopy(localBytes, readPosition.toInt(), buffer, offset, toRead) - readPosition += toRead - bytesRemaining -= toRead - bytesTransferred(toRead) - return toRead - } - - override fun close() { - if (closed.compareAndSet(false, true)) { - fullBytes = null - opened = false - transferEnded() - } - } - - class Factory( - private val keychainHolder: KeychainHolder - ) : DataSource.Factory { - override fun createDataSource(): DataSource { - throw IllegalStateException("Use createForFile(file) supplying the encrypted file") - } - fun createForFile(file: File): StreamingEncryptedDataSource = StreamingEncryptedDataSource(file, keychainHolder) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorker.kt new file mode 100644 index 0000000000..cc465f31c2 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorker.kt @@ -0,0 +1,44 @@ +package com.dot.gallery.core.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException + +@HiltWorker +class AiMediaAnalysisCleanupWorker @AssistedInject internal constructor( + private val repository: AiMediaAnalysisRepository, + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return try { + cleanPendingGeneratedData() + Result.success() + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + Result.retry() + } + } + + private suspend fun cleanPendingGeneratedData() { + val preferences = repository.getPreferences() + when { + preferences.analysisCleanupPending -> { + repository.clearAllGeneratedData() + repository.completeAnalysisCleanup() + } + + preferences.categoryCleanupPending -> { + repository.clearCategoryGeneratedData() + repository.completeCategoryCleanup() + } + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/CategoryWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/CategoryWorker.kt index a0a1650c15..dc0229e316 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/CategoryWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/CategoryWorker.kt @@ -6,36 +6,31 @@ package com.dot.gallery.core.workers import android.content.Context -import android.os.Build -import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.fastForEachIndexed -import androidx.compose.ui.util.fastMap import androidx.hilt.work.HiltWorker -import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkInfo -import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.dot.gallery.core.Settings import com.dot.gallery.core.ml.ModelManager +import com.dot.gallery.core.ml.ModelStatus import com.dot.gallery.core.util.ProgressThrottler +import com.dot.gallery.feature_node.data.data_source.GeneratedMediaCategoryStaging import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.MediaCategory +import com.dot.gallery.feature_node.data.data_source.flatMapIdChunks +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository import com.dot.gallery.feature_node.presentation.search.helpers.SearchVisionHelper import com.dot.gallery.feature_node.presentation.search.util.dot import com.dot.gallery.feature_node.presentation.util.printInfo import com.dot.gallery.feature_node.presentation.util.printWarning import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.isActive +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext /** * Worker that classifies media into categories using CLIP embeddings. @@ -47,28 +42,46 @@ import kotlinx.coroutines.isActive * 4. Associates media with categories based on similarity threshold */ @HiltWorker -class CategoryWorker @AssistedInject constructor( +class CategoryWorker @AssistedInject internal constructor( private val database: InternalDatabase, + private val analysisRepository: AiMediaAnalysisRepository, private val modelManager: ModelManager, - @Assisted private val appContext: Context, - @Assisted workerParams: WorkerParameters + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, ) : CoroutineWorker(appContext, workerParams) { private val visionHelper by lazy { SearchVisionHelper(modelManager) } - override suspend fun doWork(): Result = runCatching { + override suspend fun doWork(): Result { + return try { + classifyMedia() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + printWarning("CategoryWorker failed: ${exception.message}") + Result.failure() + } + } + + private suspend fun classifyMedia(): Result { printInfo("CategoryWorker starting classification") - - // Check if classification is disabled - val noClassification = Settings.Misc.getSetting(appContext, Settings.Misc.NO_CLASSIFICATION, false) - .firstOrNull() ?: false - if (noClassification) { + + val analysisPreferences = analysisRepository.getPreferences() + if (!analysisPreferences.analysisEnabled || !analysisPreferences.categoryClassificationEnabled) { printInfo("CategoryWorker: Classification is disabled") return Result.success() } - if (!modelManager.isReady) { - printInfo("CategoryWorker: ML models not installed, skipping") + val forceClassification = inputData.getBoolean(KEY_FORCE_CLASSIFICATION, false) + val changedCount = inputData.getInt(SearchIndexerUpdaterWorker.KEY_CHANGED_COUNT, 0) + if (!forceClassification && changedCount == 0) { + printInfo("CategoryWorker: No changed media to classify") + return Result.success() + } + + val modelStatus = modelManager.status.first { status -> status != ModelStatus.CHECKING } + if (modelStatus != ModelStatus.READY) { + printInfo("CategoryWorker: ML models unavailable, skipping") return Result.success() } @@ -79,11 +92,11 @@ class CategoryWorker @AssistedInject constructor( // Get all categories var categories = categoryDao.getAllCategoriesAsync() - + // If no categories exist, initialize with default categories if (categories.isEmpty()) { printInfo("CategoryWorker: No categories found, initializing defaults") - categoryDao.insertCategories(Category.DEFAULT_CATEGORIES) + categoryDao.insertCategories(categories = Category.DEFAULT_CATEGORIES) categories = categoryDao.getAllCategoriesAsync() } @@ -92,235 +105,213 @@ class CategoryWorker @AssistedInject constructor( return Result.success() } - // Get all image embeddings - var imageEmbeddings = embeddingDao.getRecords().firstOrNull() ?: emptyList() - if (imageEmbeddings.isEmpty()) { - printInfo("CategoryWorker: No image embeddings found, starting search indexer...") - setProgress(workDataOf(KEY_PROGRESS to 0f, KEY_STATUS to "Starting image indexer...")) - - // Start the search indexer - startSearchIndexer() - - // Wait for the search indexer to complete (with timeout) - val workManager = WorkManager.getInstance(appContext) - var attempts = 0 - val maxAttempts = 600 // 10 minutes max wait (600 * 1 second) - - while (attempts < maxAttempts && currentCoroutineContext().isActive && !isStopped) { - delay(1000) // Wait 1 second between checks - attempts++ - - val workInfos = workManager.getWorkInfosByTag("SearchIndexerUpdater").get() - val isRunning = workInfos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } - - if (!isRunning) { - // Check if we now have embeddings - imageEmbeddings = embeddingDao.getRecords().firstOrNull() ?: emptyList() - if (imageEmbeddings.isNotEmpty()) { - printInfo("CategoryWorker: Search indexer completed, found ${imageEmbeddings.size} embeddings") - break - } else { - printWarning("CategoryWorker: Search indexer finished but no embeddings found") - setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STATUS to "No images to classify")) + val preparedCategories = prepareCategories(categories = categories) + val imageCount = embeddingDao.getCount() + printInfo( + "CategoryWorker: Processing ${preparedCategories.size} categories and ${imageCount} images", + ) + + val generationId = id.toString() + currentCoroutineContext().ensureActive() + if (isStopped || !isClassificationEnabled()) { + return Result.success() + } + categoryDao.beginGeneratedMediaCategoryStaging(generationId = generationId) + var published = false + try { + val throttler = ProgressThrottler() + var processedImages = 0 + var afterId = Long.MIN_VALUE + while (true) { + currentCoroutineContext().ensureActive() + if (isStopped || !isClassificationEnabled()) { + return Result.success() + } + + val page = embeddingDao.getPage(afterId = afterId, limit = EMBEDDING_PAGE_SIZE) + + if (page.isEmpty()) { + break + } + + val matches = classifyPage( + imageEmbeddings = page, + categories = preparedCategories, + generationId = generationId, + ) + + if (matches.isNotEmpty()) { + val staged = categoryDao.stageGeneratedMediaCategories( + generationId = generationId, + mediaCategories = matches, + ) + if (!staged) { + printInfo("CategoryWorker: Classification was superseded") return Result.success() } } - - // Update progress to show we're waiting - if (attempts % 5 == 0) { - val progress = workInfos.firstOrNull { it.state == WorkInfo.State.RUNNING } - ?.progress?.getFloat("progress", 0f) ?: 0f - setProgress(workDataOf( - KEY_PROGRESS to (progress * 0.5f).coerceIn(0f, 50f), // Use first 50% for indexing - KEY_STATUS to "Indexing images... ${progress.toInt()}%" - )) + processedImages += page.size + + val progress = when { + imageCount == 0 -> 99 + else -> { + ((processedImages.toFloat() / imageCount.toFloat()) * 99f) + .toInt() + .coerceIn(0, 99) + } } + + throttler.emit(progress) { percentage -> + setProgress( + workDataOf( + KEY_PROGRESS to percentage.toFloat(), + KEY_STATUS to "Classifying media...", + ), + ) + } + afterId = page.last().id } - - if (imageEmbeddings.isEmpty()) { - printWarning("CategoryWorker: Timed out waiting for search indexer") - setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STATUS to "Indexer timed out")) + + currentCoroutineContext().ensureActive() + if (!isClassificationEnabled()) { return Result.success() } - } - printInfo("CategoryWorker: Processing ${categories.size} categories and ${imageEmbeddings.size} images") - - // Set up text session for generating category embeddings - val textSession = visionHelper.setupTextSession() - - val throttler = ProgressThrottler() - val totalSteps = categories.size - - textSession.use { session -> - categories.fastForEachIndexed { categoryIndex, category -> - if (!currentCoroutineContext().isActive || isStopped) return@use - - val pct = ((categoryIndex.toFloat() / totalSteps.toFloat()) * 100f).coerceIn(0f, 99f) - throttler.emit(pct.toInt()) { - setProgress(workDataOf( - KEY_PROGRESS to it.toFloat(), - KEY_STATUS to "Processing ${category.name}...", - KEY_CURRENT_CATEGORY to category.name - )) - } + published = categoryDao.replaceGeneratedMediaCategoriesFromStaging( + generationId = generationId, + ) - // Generate text embedding for the category's search terms (if any) - val categoryEmbedding = if (category.searchTerms.isNotBlank()) { - category.embedding ?: run { - val embedding = visionHelper.getTextEmbedding(session, category.searchTerms) - categoryDao.updateCategory(category.copy( - embedding = embedding, - updatedAt = System.currentTimeMillis() - )) - embedding - } - } else null + if (!published) { + printInfo("CategoryWorker: Classification was superseded") + return Result.success() + } - // Collect reference image embeddings for image-to-image matching - val refIdSet = category.referenceImageIds.toSet() - val refEmbeddings = if (refIdSet.isNotEmpty()) { - imageEmbeddings.filter { it.id in refIdSet } - } else emptyList() + setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STATUS to "Complete")) + printInfo("CategoryWorker: Classification complete") - if (categoryEmbedding == null && refEmbeddings.isEmpty()) { - printInfo("CategoryWorker: Category '${category.name}' has no text or reference images, skipping") - return@fastForEachIndexed + return Result.success() + } finally { + if (!published) { + withContext(NonCancellable) { + categoryDao.abandonGeneratedMediaCategoryStaging(generationId = generationId) } + } + } + } - // Find matching media - val matchingMedia = mutableListOf() - - imageEmbeddings.fastForEach { imageEmbedding -> - // Skip reference images themselves - if (imageEmbedding.id in refIdSet) return@fastForEach + private suspend fun prepareCategories(categories: List): List { + val categoryDao = database.getCategoryDao() + val embeddingDao = database.getImageEmbeddingDao() + val referenceIds = categories + .flatMapTo(mutableSetOf()) { category -> category.referenceImageIds } + val referenceEmbeddings = when { + referenceIds.isEmpty() -> emptyMap() + else -> flatMapIdChunks(ids = referenceIds) { idChunk -> + embeddingDao.getByIds(ids = idChunk) + }.associateBy { embedding -> embedding.id } + } - var bestScore = 0f + return visionHelper.setupTextSession().use { session -> + categories.mapNotNull { category -> + currentCoroutineContext().ensureActive() + if (!isClassificationEnabled()) { + return@use emptyList() + } + val textEmbedding = when { + category.searchTerms.isBlank() -> null + category.embedding != null -> category.embedding + else -> { + val generatedEmbedding = visionHelper.getTextEmbedding( + session = session, + text = category.searchTerms, + ) + currentCoroutineContext().ensureActive() + if (!isClassificationEnabled()) { + return@use emptyList() + } + categoryDao.updateCategory( + category.copy( + embedding = generatedEmbedding, + updatedAt = System.currentTimeMillis(), + ), + ) + generatedEmbedding + } + } + + val categoryReferenceEmbeddings = category.referenceImageIds.mapNotNull { id -> + referenceEmbeddings[id] + } - // Text-to-image similarity - if (categoryEmbedding != null) { - bestScore = maxOf(bestScore, categoryEmbedding.dot(imageEmbedding.embedding)) + when { + textEmbedding == null && categoryReferenceEmbeddings.isEmpty() -> null + else -> { + PreparedCategory( + category = category, + textEmbedding = textEmbedding, + referenceIds = category.referenceImageIds.toSet(), + referenceEmbeddings = categoryReferenceEmbeddings, + ) } + } + } + } + } - // Image-to-image similarity (against each reference) - refEmbeddings.fastForEach { ref -> - bestScore = maxOf(bestScore, ref.embedding.dot(imageEmbedding.embedding)) + private fun classifyPage( + imageEmbeddings: List, + categories: List, + generationId: String, + ): List { + val addedAt = System.currentTimeMillis() + return buildList { + imageEmbeddings.forEach { imageEmbedding -> + for (preparedCategory in categories) { + val category = preparedCategory.category + if (imageEmbedding.id in preparedCategory.referenceIds) { + continue + } + var bestScore = preparedCategory.textEmbedding?.dot(imageEmbedding.embedding) ?: 0f + preparedCategory.referenceEmbeddings.forEach { referenceEmbedding -> + bestScore = maxOf( + bestScore, + referenceEmbedding.embedding.dot(imageEmbedding.embedding), + ) } - if (bestScore >= category.threshold) { - matchingMedia.add( - MediaCategory( + add( + GeneratedMediaCategoryStaging( + generationId = generationId, mediaId = imageEmbedding.id, categoryId = category.id, - similarityScore = bestScore - ) + similarityScore = bestScore, + addedAt = addedAt, + ), ) } } - - printInfo("CategoryWorker: Category '${category.name}' matched ${matchingMedia.size} media items") - - // Update the database with the matches - if (matchingMedia.isNotEmpty()) { - categoryDao.reclassifyMediaForCategory(category.id, matchingMedia) - } } } - - // Clean up orphaned media-category entries (media that no longer exists) - val validMediaIds = imageEmbeddings.fastMap { it.id } - categoryDao.cleanupOrphanedMediaCategories(validMediaIds) - - setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STATUS to "Complete")) - printInfo("CategoryWorker: Classification complete") - - return Result.success() - }.getOrElse { exception -> - printWarning("CategoryWorker failed: ${exception.message}") - exception.printStackTrace() - return Result.failure() } - /** - * Starts the search indexer to create image embeddings - */ - private fun startSearchIndexer() { - val workManager = WorkManager.getInstance(appContext) - val constraints = Constraints.Builder() - .setRequiresStorageNotLow(true) - .build() - - val searchIndexerWork = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - } - .addTag("SearchIndexerUpdater") - .build() - - workManager.enqueueUniqueWork( - "SearchIndexerUpdater", - ExistingWorkPolicy.KEEP, - searchIndexerWork - ) + private suspend fun isClassificationEnabled(): Boolean { + val preferences = analysisRepository.getPreferences() + return preferences.analysisEnabled && preferences.categoryClassificationEnabled } companion object { + const val KEY_FORCE_CLASSIFICATION = "force_classification" const val KEY_PROGRESS = "progress" const val KEY_STATUS = "status" const val KEY_CURRENT_CATEGORY = "current_category" - const val WORK_NAME = "CategoryWorker" const val TAG = "CategoryClassifier" + private const val EMBEDDING_PAGE_SIZE = 256 } -} -/** - * Extension function to start category classification - */ -fun WorkManager.startCategoryClassification() { - val request = OneTimeWorkRequestBuilder() - .addTag(CategoryWorker.TAG) - .setConstraints( - Constraints.Builder() - .setRequiresStorageNotLow(true) - .build() - ) - .build() - - enqueueUniqueWork( - CategoryWorker.WORK_NAME, - ExistingWorkPolicy.REPLACE, - request - ) -} - -/** - * Extension function to stop category classification - */ -fun WorkManager.stopCategoryClassification() { - cancelUniqueWork(CategoryWorker.WORK_NAME) -} - -/** - * Extension function to reclassify a single category - */ -fun WorkManager.reclassifyCategory(categoryId: Long) { - val request = OneTimeWorkRequestBuilder() - .addTag(CategoryWorker.TAG) - .addTag("reclassify_$categoryId") - .setInputData(workDataOf("categoryId" to categoryId)) - .setConstraints( - Constraints.Builder() - .setRequiresStorageNotLow(true) - .build() - ) - .build() - - enqueueUniqueWork( - "${CategoryWorker.WORK_NAME}_$categoryId", - ExistingWorkPolicy.REPLACE, - request + private class PreparedCategory( + val category: Category, + val textEmbedding: FloatArray?, + val referenceIds: Set, + val referenceEmbeddings: List, ) } diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/ClassifierWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/ClassifierWorker.kt index a1e859b1de..458ec69ca7 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/ClassifierWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/ClassifierWorker.kt @@ -10,6 +10,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis import com.dot.gallery.feature_node.presentation.util.printWarning import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -22,16 +23,17 @@ import dagger.assisted.AssistedInject * @deprecated Use CategoryWorker directly via startCategoryClassification() */ @HiltWorker -class ClassifierWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted workerParams: WorkerParameters +class ClassifierWorker @AssistedInject internal constructor( + private val aiMediaAnalysis: AiMediaAnalysis, + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { printWarning("ClassifierWorker: Delegating to new CategoryWorker system") // Start the new category classification worker - WorkManager.getInstance(appContext).startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() setProgress(workDataOf("progress" to 100)) return Result.success() diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/DatabaseUpdaterWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/DatabaseUpdaterWorker.kt index bb0117c239..4b860a5573 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/DatabaseUpdaterWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/DatabaseUpdaterWorker.kt @@ -1,63 +1,46 @@ package com.dot.gallery.core.workers import android.content.Context -import android.os.Build import androidx.compose.ui.util.fastMap import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.domain.model.MediaVersion -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.MediaVersion +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.isMediaUpToDate import com.dot.gallery.feature_node.presentation.util.mediaStoreVersion import com.dot.gallery.feature_node.presentation.util.printDebug import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -fun WorkManager.updateDatabase() { +internal fun WorkManager.updateDatabase() { val workPolicy = ExistingWorkPolicy.KEEP val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) .build() - val searchIndexerWork = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - } - .addTag("SearchIndexerUpdater") - .build() - val databaseUpdaterWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) .build() val metadataWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - } .addTag("MetadataCollection") .build() - enqueueUniqueWork("SearchIndexerUpdater", workPolicy, searchIndexerWork) enqueueUniqueWork("DatabaseUpdaterWorker", workPolicy, databaseUpdaterWork) enqueueUniqueWork("MetadataCollection", workPolicy, metadataWork) } @@ -67,32 +50,39 @@ class DatabaseUpdaterWorker @AssistedInject constructor( private val database: InternalDatabase, private val repository: MediaRepository, @Assisted private val appContext: Context, - @Assisted workerParams: WorkerParameters + @Assisted workerParams: WorkerParameters, ) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result = runCatching { - delay(5000) - if (!currentCoroutineContext().isActive || isStopped) return Result.success() - if (database.isMediaUpToDate(appContext)) { - printDebug("Database is up to date") - return Result.success() - } - withContext(Dispatchers.IO) { - val mediaVersion = appContext.mediaStoreVersion - val media = - repository.getCompleteMedia().map { it.data ?: emptyList() }.firstOrNull() - media?.let { - printDebug("Database is not up to date. Updating to version $mediaVersion") - database.getMediaDao().setMediaVersion(MediaVersion(mediaVersion)) - database.getMediaDao().updateMedia(it) - database.getClassifierDao().deleteDeclassifiedImages(it.fastMap { m -> m.id }) + override suspend fun doWork(): Result { + return try { + delay(5000) + if (!currentCoroutineContext().isActive || isStopped) { + return Result.success() + } + if (database.isMediaUpToDate(appContext)) { + printDebug("Database is up to date") + return Result.success() } + withContext(Dispatchers.IO) { + val mediaVersion = appContext.mediaStoreVersion + val media = repository.getCompleteMedia() + .map { resource -> resource.data.orEmpty() } + .firstOrNull() + media?.let { mediaItems -> + printDebug("Database is not up to date. Updating to version $mediaVersion") + database.getMediaDao().setMediaVersion(MediaVersion(mediaVersion)) + database.getMediaDao().updateMedia(mediaItems) + database.getClassifierDao().deleteDeclassifiedImages( + mediaItems.fastMap { mediaItem -> mediaItem.id }, + ) + } + } + Result.success() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + printDebug("Error updating database: ${exception.message}") + Result.failure() } - - return@runCatching Result.success() - }.getOrElse { exception -> - printDebug("Error updating database: ${exception.message}") - Result.failure() } } - diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/EditBackupWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/EditBackupWorker.kt deleted file mode 100644 index 895f561b97..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/EditBackupWorker.kt +++ /dev/null @@ -1,192 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.core.workers - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.dot.gallery.core.EditBackupManager -import com.dot.gallery.feature_node.presentation.util.printDebug -import com.dot.gallery.feature_node.presentation.util.printError -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.UUID - -/** - * WorkManager-backed worker for heavy edit-backup I/O operations: - * - REVERT: stream-copy the original back to the media URI - * - AUTO_CLEANUP: delete backups older than 90 days - * - DELETE_SELECTED: delete specific backups by media IDs - * - DELETE_ALL: delete every backup - */ -@HiltWorker -class EditBackupWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted params: WorkerParameters, - private val editBackupManager: EditBackupManager -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val op = inputData.getString(KEY_OPERATION) - ?: return@withContext failure("Missing operation") - - try { - when (op) { - OP_REVERT -> { - val mediaId = inputData.getLong(KEY_MEDIA_ID, -1L) - if (mediaId == -1L) return@withContext failure("Missing media ID") - printDebug("EditBackupWorker: reverting mediaId=$mediaId") - val success = editBackupManager.revertToOriginal(mediaId) - if (success) { - success("Reverted mediaId=$mediaId") - } else { - failure("Revert failed for mediaId=$mediaId") - } - } - - OP_AUTO_CLEANUP -> { - val maxAgeMs = inputData.getLong( - KEY_MAX_AGE_MS, EditBackupManager.AUTO_CLEANUP_AGE_MS - ) - printDebug("EditBackupWorker: auto-cleanup older than ${maxAgeMs}ms") - val count = editBackupManager.deleteBackupsOlderThan(maxAgeMs) - success( - "Cleaned up $count backup(s)", - extra = workDataOf(KEY_DELETED_COUNT to count) - ) - } - - OP_DELETE_SELECTED -> { - val ids = inputData.getLongArray(KEY_MEDIA_IDS) - ?: return@withContext failure("Missing media IDs") - printDebug("EditBackupWorker: deleting ${ids.size} selected backup(s)") - editBackupManager.deleteBackups(ids.toList()) - success("Deleted ${ids.size} backup(s)") - } - - OP_DELETE_ALL -> { - printDebug("EditBackupWorker: deleting all backups") - editBackupManager.deleteAllBackups() - success("All backups deleted") - } - - else -> failure("Unknown operation: $op") - } - } catch (e: Exception) { - printError("EditBackupWorker failed ($op): ${e.message}") - failure("Error: ${e.message}") - } - } - - private fun success(msg: String, extra: Data? = null): Result = - Result.success( - Data.Builder() - .putBoolean(KEY_SUCCESS, true) - .putString(KEY_MESSAGE, msg) - .apply { - extra?.keyValueMap?.forEach { (k, v) -> - when (v) { - is String -> putString(k, v) - is Int -> putInt(k, v) - is Long -> putLong(k, v) - is Boolean -> putBoolean(k, v) - } - } - } - .build() - ) - - private fun failure(msg: String): Result = - Result.failure( - Data.Builder() - .putBoolean(KEY_SUCCESS, false) - .putString(KEY_MESSAGE, msg) - .build() - ) - - companion object { - const val TAG = "EditBackupWorker" - - const val KEY_OPERATION = "operation" - const val KEY_MEDIA_ID = "media_id" - const val KEY_MEDIA_IDS = "media_ids" - const val KEY_MAX_AGE_MS = "max_age_ms" - const val KEY_SUCCESS = "success" - const val KEY_MESSAGE = "message" - const val KEY_DELETED_COUNT = "deleted_count" - - const val OP_REVERT = "revert" - const val OP_AUTO_CLEANUP = "auto_cleanup" - const val OP_DELETE_SELECTED = "delete_selected" - const val OP_DELETE_ALL = "delete_all" - } -} - -// ─── WorkManager extension helpers ─── - -fun WorkManager.revertEditBackup(mediaId: Long): UUID { - val work = OneTimeWorkRequestBuilder() - .addTag(EditBackupWorker.TAG) - .setInputData( - workDataOf( - EditBackupWorker.KEY_OPERATION to EditBackupWorker.OP_REVERT, - EditBackupWorker.KEY_MEDIA_ID to mediaId - ) - ) - .build() - enqueue(work) - return work.id -} - -fun WorkManager.autoCleanupEditBackups( - maxAgeMs: Long = EditBackupManager.AUTO_CLEANUP_AGE_MS -): UUID { - val work = OneTimeWorkRequestBuilder() - .addTag(EditBackupWorker.TAG) - .setInputData( - workDataOf( - EditBackupWorker.KEY_OPERATION to EditBackupWorker.OP_AUTO_CLEANUP, - EditBackupWorker.KEY_MAX_AGE_MS to maxAgeMs - ) - ) - .build() - enqueue(work) - return work.id -} - -fun WorkManager.deleteSelectedEditBackups(mediaIds: LongArray): UUID { - val work = OneTimeWorkRequestBuilder() - .addTag(EditBackupWorker.TAG) - .setInputData( - workDataOf( - EditBackupWorker.KEY_OPERATION to EditBackupWorker.OP_DELETE_SELECTED, - EditBackupWorker.KEY_MEDIA_IDS to mediaIds - ) - ) - .build() - enqueue(work) - return work.id -} - -fun WorkManager.deleteAllEditBackups(): UUID { - val work = OneTimeWorkRequestBuilder() - .addTag(EditBackupWorker.TAG) - .setInputData( - workDataOf( - EditBackupWorker.KEY_OPERATION to EditBackupWorker.OP_DELETE_ALL - ) - ) - .build() - enqueue(work) - return work.id -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyModels.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyModels.kt new file mode 100644 index 0000000000..d3f4ea4276 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyModels.kt @@ -0,0 +1,27 @@ +package com.dot.gallery.core.workers + +import android.net.Uri + +internal data class MediaCopyRequest( + val sourceUri: Uri, + val destinationPath: String, +) + +internal data class MediaCopyBatch( + val tag: String, + val workRequestCount: Int, + val itemCount: Int, +) + +internal sealed interface MediaCopyBatchStatus { + + data class Copying(val progress: Float) : MediaCopyBatchStatus + + data class Finished( + val copiedCount: Int, + val failedCount: Int, + val successful: Boolean, + ) : MediaCopyBatchStatus + + data object Unavailable : MediaCopyBatchStatus +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyScheduler.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyScheduler.kt new file mode 100644 index 0000000000..1a21f9c324 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyScheduler.kt @@ -0,0 +1,165 @@ +package com.dot.gallery.core.workers + +import android.util.Log +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +internal interface MediaCopyScheduler { + + fun prepareBatch(requests: List): MediaCopyBatch + + suspend fun enqueue(batch: MediaCopyBatch, requests: List) + + fun observe(batch: MediaCopyBatch): Flow +} + +internal class MediaCopySchedulerImpl @Inject constructor( + private val workManager: WorkManager, +) : MediaCopyScheduler { + + override fun prepareBatch(requests: List): MediaCopyBatch { + require(requests.isNotEmpty()) { "At least one media copy request is required" } + + return MediaCopyBatch( + tag = "${MEDIA_COPY_BATCH_TAG_PREFIX}${UUID.randomUUID()}", + workRequestCount = calculateWorkRequestCount(itemCount = requests.size), + itemCount = requests.size, + ) + } + + override suspend fun enqueue( + batch: MediaCopyBatch, + requests: List, + ) { + require(requests.isNotEmpty()) { "At least one media copy request is required" } + require(batch.tag.isNotBlank()) { "Media copy batch tag must not be blank" } + require(batch.itemCount == requests.size) { "Media copy batch item count does not match" } + require( + batch.workRequestCount == calculateWorkRequestCount(itemCount = requests.size), + ) { "Media copy batch work request count does not match" } + + try { + val workRequests = requests.chunked(MEDIA_PER_WORK_REQUEST).map { chunk -> + OneTimeWorkRequestBuilder() + .addTag(MEDIA_COPY_WORKER_TAG) + .addTag(batch.tag) + .setInputData( + workDataOf( + "uris" to chunk.map { request -> + request.sourceUri.toString() + }.toTypedArray(), + "paths" to chunk.map { request -> + request.destinationPath + }.toTypedArray(), + ), + ) + .build() + } + workManager.enqueue(workRequests).await() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Unable to confirm media copy batch enqueue", exception) + } + } + + override fun observe(batch: MediaCopyBatch): Flow { + return workManager.getWorkInfosByTagFlow(batch.tag) + .map { workInfos -> + toStatus( + batch = batch, + workInfos = workInfos, + ) + } + .catch { exception -> + when (exception) { + is CancellationException -> throw exception + is Exception -> emit(MediaCopyBatchStatus.Unavailable) + else -> throw exception + } + } + } + + internal fun toStatus( + batch: MediaCopyBatch, + workInfos: List, + ): MediaCopyBatchStatus { + if (workInfos.size != batch.workRequestCount) { + return MediaCopyBatchStatus.Unavailable + } + + return when { + workInfos.all { workInfo -> workInfo.state.isFinished } -> { + terminalStatus( + batch = batch, + workInfos = workInfos, + ) + } + + else -> MediaCopyBatchStatus.Copying( + progress = averageProgress(workInfos = workInfos), + ) + } + } + + private fun terminalStatus( + batch: MediaCopyBatch, + workInfos: List, + ): MediaCopyBatchStatus { + val copiedCount = workInfos.sumOf { workInfo -> + workInfo.outputData.getInt(MediaCopyWorker.MEDIA_COPY_SUCCESSFUL_COUNT_KEY, 0) + }.coerceIn(0, batch.itemCount) + + val reportedFailedCount = workInfos.sumOf { workInfo -> + workInfo.outputData.getInt(MediaCopyWorker.MEDIA_COPY_FAILED_COUNT_KEY, 0) + } + + val failedCount = maxOf(batch.itemCount - copiedCount, reportedFailedCount) + .coerceIn(0, batch.itemCount) + + val successful = copiedCount == batch.itemCount && workInfos.all { workInfo -> + workInfo.state == WorkInfo.State.SUCCEEDED + } + + return MediaCopyBatchStatus.Finished( + copiedCount = copiedCount, + failedCount = failedCount, + successful = successful, + ) + } + + private fun averageProgress(workInfos: List): Float { + val progressTotal = workInfos.sumOf { workInfo -> + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> 100 + else -> { + workInfo + .progress + .getInt(MediaCopyWorker.MEDIA_COPY_PROGRESS_KEY, 0) + } + } + } + return (progressTotal.toFloat() / workInfos.size.toFloat()) + .coerceIn(0f, 100f) / 100f + } + + private fun calculateWorkRequestCount(itemCount: Int): Int { + return (itemCount + MEDIA_PER_WORK_REQUEST - 1) / MEDIA_PER_WORK_REQUEST + } + + companion object { + private const val MEDIA_COPY_BATCH_TAG_PREFIX = "MediaCopyBatch_" + private const val MEDIA_COPY_WORKER_TAG = "MediaCopyWorker" + private const val MEDIA_PER_WORK_REQUEST = 32 + private const val TAG = "MediaCopyScheduler" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyWorker.kt index e6165f1433..17d5426518 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/MediaCopyWorker.kt @@ -1,164 +1,194 @@ package com.dot.gallery.core.workers -import android.content.ContentResolver -import android.content.ContentValues import android.content.Context -import android.os.Build -import android.provider.MediaStore +import android.net.Uri import androidx.core.net.toUri import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager +import androidx.work.ListenableWorker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.dot.gallery.core.util.ProgressThrottler -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.data.repository.MediaCopyRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import java.io.IOException -import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +@HiltWorker +internal class MediaCopyWorker @AssistedInject constructor( + private val mediaCopyRepository: MediaCopyRepository, + @Assisted appContext: Context, + @Assisted private val params: WorkerParameters, +) : CoroutineWorker(appContext, params) { -fun WorkManager.copyMedia(vararg sets: Pair) { - if (sets.isEmpty()) return - sets.toList().chunked(32).forEachIndexed { index, chunk -> - val uris = chunk.map { it.first.getUri().toString() }.toTypedArray() - val paths = chunk.map { it.second }.toTypedArray() - - val request = OneTimeWorkRequestBuilder() - .addTag("MediaCopyWorker") - .addTag("MediaCopyWorker_${index + 1}_${chunk.size}") - .setInputData( - workDataOf( - "uris" to uris, - "paths" to paths - ) + override suspend fun doWork(): Result { + val copyRequests = getCopyRequests() + ?: return mediaCopyWorkResult( + successfulCount = 0, + failedCount = getSuppliedItemCount(), ) - .build() + val sourceUris = copyRequests.map { copyRequest -> copyRequest.first } + val totalBytes = getTotalBytes(sourceUris = sourceUris) + val copyResults = copyMedia( + copyRequests = copyRequests, + totalBytes = totalBytes, + ) + + if (copyResults.any { uri -> uri != null }) { + mediaCopyRepository.notifyMediaChanged() + } - enqueue(request) + if (copyResults.allCopiesSucceeded() && currentCoroutineContext().isActive) { + setProgress(workDataOf(MEDIA_COPY_PROGRESS_KEY to 100)) + } + + return copyResults.toMediaCopyWorkResult() } -} + private fun getCopyRequests(): List>? { + val uriStrings = params.inputData.getStringArray("uris") + val destinationPaths = params.inputData.getStringArray("paths") -fun WorkManager.copyMedia( - from: Media.UriMedia, - path: String, -) = copyMedia(from to path) + return when { + uriStrings == null || destinationPaths == null -> null + uriStrings.isEmpty() || uriStrings.size != destinationPaths.size -> null + else -> uriStrings.map { uriString -> uriString.toUri() }.zip(destinationPaths) + } + } -@HiltWorker -class MediaCopyWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val params: WorkerParameters -) : CoroutineWorker(appContext, params) { + private fun getSuppliedItemCount(): Int { + val uriCount = params.inputData.getStringArray("uris")?.size ?: 0 + val pathCount = params.inputData.getStringArray("paths")?.size ?: 0 + return maxOf(uriCount, pathCount) + } - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val uris = params.inputData.getStringArray("uris") ?: return@withContext Result.failure() - val paths = params.inputData.getStringArray("paths") ?: return@withContext Result.failure() - if (uris.size != paths.size) return@withContext Result.failure() - - val total = uris.size - val completed = AtomicInteger(0) - val throttler = ProgressThrottler() - // Track byte-level progress - val bytesTotal = AtomicInteger(0) - val bytesCopied = AtomicInteger(0) - - // First, compute approximate total bytes (best-effort) to enable smoother progress. - uris.forEach { uriStr -> - try { - appContext.contentResolver.openAssetFileDescriptor(uriStr.toUri(), "r")?.use { afd -> - val length = afd.length - if (length > 0) bytesTotal.addAndGet(length.toInt().coerceAtMost(Int.MAX_VALUE)) - } - } catch (_: Throwable) { /* ignore, fallback to per-file progress */ } + private suspend fun getTotalBytes(sourceUris: List): Long { + return sourceUris.sumOf { uri -> + mediaCopyRepository.getMediaSize(uri = uri) ?: 0L } + } + + private suspend fun copyMedia( + copyRequests: List>, + totalBytes: Long, + ): List { + val totalMedia = copyRequests.size + val completedMedia = AtomicInteger(0) + val copiedBytes = AtomicLong(0) + val progressThrottler = ProgressThrottler() + val semaphore = Semaphore(MAX_CONCURRENT_COPIES) + + return coroutineScope { + copyRequests.map { (sourceUri, destinationPath) -> + async { + semaphore.withPermit { + if (!currentCoroutineContext().isActive || isStopped) { + return@withPermit null + } + + val destinationUri = mediaCopyRepository.copyMedia( + sourceUri = sourceUri, + destinationPath = destinationPath, + ) { bytesCopied -> + updateByteProgress( + bytesCopied = bytesCopied, + copiedBytes = copiedBytes, + totalBytes = totalBytes, + progressThrottler = progressThrottler, + ) + } + + val completedCount = completedMedia.incrementAndGet() + + if (totalBytes == 0L) { + updateItemProgress( + completedMedia = completedCount, + totalMedia = totalMedia, + progressThrottler = progressThrottler, + ) + } - val copyJobs = uris.zip(paths).map { (uriStr, relPath) -> - async { - if (!currentCoroutineContext().isActive || isStopped) return@async false - val uri = uriStr.toUri() - val result = copyOne(uri, relPath) { delta -> - if (bytesTotal.get() > 0) { - val newTotal = bytesCopied.addAndGet(delta) - val pctBytes = ((newTotal.toFloat() / bytesTotal.get().toFloat()) * 100f).toInt().coerceIn(0, 100) - throttler.emit(pctBytes) { value -> setProgress(workDataOf("progress" to value)) } + destinationUri } } - val done = completed.incrementAndGet() - if (bytesTotal.get() == 0) { - val pct = ((done.toFloat() / total.toFloat()) * 100f).toInt().coerceIn(0, 100) - throttler.emit(pct) { value -> setProgress(workDataOf("progress" to value)) } - } - result - } + }.awaitAll() } + } - val results = copyJobs.map { it.await() } - when { - results.all { it } -> { - if (isActive) { - setProgress(workDataOf("progress" to 100)) - } - Result.success() - } + private suspend fun updateByteProgress( + bytesCopied: Int, + copiedBytes: AtomicLong, + totalBytes: Long, + progressThrottler: ProgressThrottler, + ) { + if (totalBytes <= 0L) { + return + } + + val copiedByteCount = copiedBytes.addAndGet(bytesCopied.toLong()) + val progress = ((copiedByteCount.toFloat() / totalBytes.toFloat()) * 100f) + .toInt() + .coerceIn(0, 100) - results.any { !it } -> Result.retry() - else -> Result.failure() + progressThrottler.emit(progress) { value -> + setProgress(workDataOf(MEDIA_COPY_PROGRESS_KEY to value)) } } - private suspend fun copyOne(src: android.net.Uri, relPath: String, onBytesCopied: suspend (Int) -> Unit = {}): Boolean = - withContext(Dispatchers.IO) { - val cr: ContentResolver = appContext.contentResolver - try { - val mediaType = cr.getType(src) ?: return@withContext false - val isVideo = mediaType.startsWith("video") - val targetUri = cr.insert( - if (isVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, src.lastPathSegment) - put(MediaStore.MediaColumns.MIME_TYPE, mediaType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relPath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - ) ?: return@withContext false - - cr.openInputStream(src).use { input -> - cr.openOutputStream(targetUri).use { output -> - if (input != null && output != null) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - onBytesCopied(read) - } - } - } - } - - val updateValues = ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put( - MediaStore.MediaColumns.DATE_MODIFIED, - System.currentTimeMillis() / 1000 - ) - } - } - return@withContext cr.update(targetUri, updateValues, null, null) > 0 - } catch (e: IOException) { - if (e.message?.contains("ENOSPC") == true) return@withContext false // will retry - return@withContext false - } + private suspend fun updateItemProgress( + completedMedia: Int, + totalMedia: Int, + progressThrottler: ProgressThrottler, + ) { + val progress = ((completedMedia.toFloat() / totalMedia.toFloat()) * 100f) + .toInt() + .coerceIn(0, 100) + + progressThrottler.emit(progress) { value -> + setProgress(workDataOf(MEDIA_COPY_PROGRESS_KEY to value)) } + } + + companion object { + internal const val MEDIA_COPY_FAILED_COUNT_KEY = "failed_count" + internal const val MEDIA_COPY_PROGRESS_KEY = "progress" + internal const val MEDIA_COPY_SUCCESSFUL_COUNT_KEY = "successful_count" + + private const val MAX_CONCURRENT_COPIES = 4 + } } +internal fun List.toMediaCopyWorkResult(): ListenableWorker.Result { + val successfulCount = count { uri -> uri != null } + return mediaCopyWorkResult( + successfulCount = successfulCount, + failedCount = size - successfulCount, + ) +} + +private fun List.allCopiesSucceeded(): Boolean { + return isNotEmpty() && all { uri -> uri != null } +} + +private fun mediaCopyWorkResult( + successfulCount: Int, + failedCount: Int, +): ListenableWorker.Result { + val outputData = workDataOf( + MediaCopyWorker.MEDIA_COPY_SUCCESSFUL_COUNT_KEY to successfulCount, + MediaCopyWorker.MEDIA_COPY_FAILED_COUNT_KEY to failedCount, + ) + + return when { + failedCount == 0 && successfulCount > 0 -> ListenableWorker.Result.success(outputData) + else -> ListenableWorker.Result.failure(outputData) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/MetadataCollectionWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/MetadataCollectionWorker.kt index 8de07f7e79..2f8b8a87d6 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/MetadataCollectionWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/MetadataCollectionWorker.kt @@ -11,23 +11,23 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.dot.gallery.BuildConfig +import com.dot.gallery.core.Settings import com.dot.gallery.core.sandbox.IsolatedMetadataParser import com.dot.gallery.core.util.ProgressThrottler import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.domain.model.MediaVersion -import com.dot.gallery.feature_node.domain.model.retrieveExtraMediaMetadata -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.MediaVersion +import com.dot.gallery.feature_node.data.model.retrieveExtraMediaMetadata +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.isMetadataUpToDate import com.dot.gallery.feature_node.presentation.util.mediaStoreVersion import com.dot.gallery.feature_node.presentation.util.printDebug import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlin.math.roundToInt import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive -import kotlin.math.roundToInt fun WorkManager.forceMetadataCollect() { val metadataWork = OneTimeWorkRequestBuilder() @@ -49,7 +49,6 @@ class MetadataCollectionWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result = runCatching { - if (!BuildConfig.ENABLE_INDEXING) return Result.success() val forceReload = inputData.getBoolean("forceReload", false) if (database.isMetadataUpToDate(appContext) && !forceReload) { printDebug("Metadata is up to date") @@ -78,7 +77,10 @@ class MetadataCollectionWorker @AssistedInject constructor( setProgress(workDataOf("progress" to 100)) return Result.success() } - printDebug("Updating metadata for ${diffMedia.size} items...") + val isolationMode = Settings.Security.getMetadataIsolationMode(appContext) + .firstOrNull() ?: Settings.Security.DEFAULT_METADATA_ISOLATION_MODE + val usePerFile = isolationMode == Settings.Security.METADATA_ISOLATION_PER_FILE + printDebug("Updating metadata for ${diffMedia.size} items... (isolation=$isolationMode)") val throttler = ProgressThrottler() val total = diffMedia.size diffMedia.fastForEachIndexed { index, it -> @@ -86,7 +88,7 @@ class MetadataCollectionWorker @AssistedInject constructor( val pct = if (total <= 1) 100 else (((index + 1).toFloat() / total.toFloat()) * 100f).roundToInt() throttler.emit(pct) { setProgress(workDataOf("progress" to it)) } - appContext.retrieveExtraMediaMetadata(isolatedParser, geocoder, it)?.let { metadata -> + appContext.retrieveExtraMediaMetadata(isolatedParser, geocoder, it, usePerFile)?.let { metadata -> database.getMetadataDao().addMetadata(metadata) } } @@ -99,4 +101,4 @@ class MetadataCollectionWorker @AssistedInject constructor( printDebug("MetadataCollectionWorker failed with exception: ${exception.message}") return Result.failure() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/ModelDownloadWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/ModelDownloadWorker.kt deleted file mode 100644 index 266c783e9a..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/ModelDownloadWorker.kt +++ /dev/null @@ -1,325 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.core.workers - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.StatFs -import androidx.core.app.NotificationCompat -import androidx.hilt.work.HiltWorker -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.dot.gallery.R -import com.dot.gallery.core.ml.DownloadInfo -import com.dot.gallery.core.ml.ModelManager -import com.dot.gallery.feature_node.presentation.util.printInfo -import com.dot.gallery.feature_node.presentation.util.printWarning -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL - -@HiltWorker -class ModelDownloadWorker @AssistedInject constructor( - private val modelManager: ModelManager, - @Assisted private val appContext: Context, - @Assisted workerParams: WorkerParameters -) : CoroutineWorker(appContext, workerParams) { - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - try { - setProgress(workDataOf(KEY_PROGRESS to 0f, KEY_STATUS to "Starting download...")) - modelManager.updateDownloadProgress(0f) - - // Create notification channel and show foreground notification - createNotificationChannel() - setForeground(createForegroundInfo(0)) - - // Check available storage - val statFs = StatFs(appContext.filesDir.path) - val availableBytes = statFs.availableBytes - if (availableBytes < MINIMUM_REQUIRED_BYTES) { - val availableMb = availableBytes / (1024 * 1024) - val requiredMb = MINIMUM_REQUIRED_BYTES / (1024 * 1024) - val error = "Not enough storage (need ${requiredMb} MB, ${availableMb} MB available)" - modelManager.onDownloadFailed(error) - setProgress(workDataOf(KEY_PROGRESS to 0f, KEY_STATUS to error)) - return@withContext Result.failure(workDataOf(KEY_ERROR to error)) - } - - val modelsDir = modelManager.modelsDir - modelsDir.mkdirs() - - val filesToDownload = ModelManager.REQUIRED_FILES - val totalFiles = filesToDownload.size - var completedFiles = 0 - - // Query total download size for all files - var totalAllBytes = 0L - val fileSizes = mutableMapOf() - filesToDownload.forEach { fileName -> - val destFile = File(modelsDir, fileName) - if (destFile.exists() && destFile.length() > 0) { - fileSizes[fileName] = destFile.length() - } else { - try { - val conn = URL("${ModelManager.BASE_DOWNLOAD_URL}$fileName").openConnection() as HttpURLConnection - conn.requestMethod = "HEAD" - conn.connectTimeout = 15_000 - conn.connect() - val len = conn.contentLengthLong - fileSizes[fileName] = if (len > 0) len else 0L - conn.disconnect() - } catch (_: Exception) { - fileSizes[fileName] = 0L - } - } - } - totalAllBytes = fileSizes.values.sum() - var downloadedBytes = 0L - - filesToDownload.forEach { fileName -> - if (!currentCoroutineContext().isActive || isStopped) { - cleanupPartialFiles(modelsDir, filesToDownload) - return@withContext Result.failure() - } - - val destFile = File(modelsDir, fileName) - - // Skip files that already exist and are non-empty - if (destFile.exists() && destFile.length() > 0) { - downloadedBytes += destFile.length() - completedFiles++ - val overallProgress = (completedFiles.toFloat() / totalFiles) * 100f - modelManager.updateDownloadProgress(overallProgress) - setProgress(workDataOf(KEY_PROGRESS to overallProgress, KEY_STATUS to "Skipping $fileName (already exists)")) - printInfo("ModelDownloadWorker: Skipping $fileName (already exists)") - return@forEach - } - - val url = "${ModelManager.BASE_DOWNLOAD_URL}$fileName" - val tempFile = File(modelsDir, "$fileName.tmp") - - try { - downloadFile(url, tempFile, fileName, completedFiles, totalFiles, downloadedBytes, totalAllBytes) - - // Atomic rename - if (!tempFile.renameTo(destFile)) { - tempFile.inputStream().use { input -> - destFile.outputStream().use { output -> - input.copyTo(output) - } - } - tempFile.delete() - } - - downloadedBytes += destFile.length() - completedFiles++ - val overallProgress = (completedFiles.toFloat() / totalFiles) * 100f - modelManager.updateDownloadProgress(overallProgress) - setProgress(workDataOf(KEY_PROGRESS to overallProgress, KEY_STATUS to "Downloaded $fileName")) - printInfo("ModelDownloadWorker: Downloaded $fileName") - - } catch (e: Exception) { - tempFile.delete() - throw e - } - } - - // Validate all files are present - modelManager.onDownloadComplete() - setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STATUS to "Complete")) - printInfo("ModelDownloadWorker: All models downloaded successfully") - return@withContext Result.success() - - } catch (e: IOException) { - // Transient network error — let WorkManager retry with backoff - val error = "Download failed (will retry): ${e.message}" - modelManager.onDownloadFailed(error) - setProgress(workDataOf(KEY_PROGRESS to 0f, KEY_STATUS to error)) - printWarning("ModelDownloadWorker: $error") - return@withContext Result.retry() - } catch (e: Exception) { - // Non-transient error — permanent failure - val error = "Download failed: ${e.message}" - modelManager.onDownloadFailed(error) - setProgress(workDataOf(KEY_PROGRESS to 0f, KEY_STATUS to error)) - printWarning("ModelDownloadWorker: $error") - return@withContext Result.failure(workDataOf(KEY_ERROR to error)) - } - } - - private fun downloadFile( - url: String, - destFile: File, - fileName: String, - completedFiles: Int, - totalFiles: Int, - previousFilesBytes: Long, - totalAllBytes: Long - ) { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connectTimeout = 30_000 - connection.readTimeout = 30_000 - connection.requestMethod = "GET" - - try { - connection.connect() - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { - throw RuntimeException("HTTP $responseCode for $url") - } - - val contentLength = connection.contentLengthLong - var bytesRead = 0L - val startTime = System.currentTimeMillis() - - connection.inputStream.use { input -> - destFile.outputStream().use { output -> - val buffer = ByteArray(65536) - var read: Int - while (input.read(buffer).also { read = it } != -1) { - output.write(buffer, 0, read) - bytesRead += read - - if (contentLength > 0) { - val fileProgress = bytesRead.toFloat() / contentLength.toFloat() - val overallProgress = ((completedFiles + fileProgress) / totalFiles) * 100f - modelManager.updateDownloadProgress(overallProgress) - - // Calculate speed - val elapsed = System.currentTimeMillis() - startTime - val speed = if (elapsed > 0) (bytesRead * 1000L) / elapsed else 0L - val totalDownloaded = previousFilesBytes + bytesRead - modelManager.updateDownloadInfo( - DownloadInfo( - speed = speed, - downloadedBytes = totalDownloaded, - totalBytes = totalAllBytes, - currentFile = fileName - ) - ) - - // Update notification periodically (~every 1MB) - if (bytesRead % (1024 * 1024) < 65536) { - setProgressAsync(workDataOf( - KEY_PROGRESS to overallProgress, - KEY_STATUS to "Downloading $fileName... ${(fileProgress * 100).toInt()}%" - )) - try { - setForegroundAsync(createForegroundInfo(overallProgress.toInt())) - } catch (_: Exception) { } - } - } - } - } - } - } finally { - connection.disconnect() - } - } - - private fun cleanupPartialFiles(modelsDir: File, files: List) { - files.forEach { fileName -> - File(modelsDir, "$fileName.tmp").delete() - } - } - - private fun createNotificationChannel() { - val channel = NotificationChannel( - CHANNEL_ID, - "AI Model Downloads", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Downloads for AI model files" - } - val notificationManager = appContext.getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(channel) - } - - private fun createForegroundInfo(progress: Int): ForegroundInfo { - val notification = NotificationCompat.Builder(appContext, CHANNEL_ID) - .setContentTitle(appContext.getString(R.string.ai_models_downloading)) - .setContentText("${progress}%") - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setProgress(100, progress, progress == 0) - .setOngoing(true) - .setSilent(true) - .build() - - val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } else { - 0 - } - return ForegroundInfo(NOTIFICATION_ID, notification, foregroundServiceType) - } - - companion object { - const val KEY_PROGRESS = "progress" - const val KEY_STATUS = "status" - const val KEY_ERROR = "error" - const val WORK_NAME = "ModelDownloadWorker" - const val TAG = "ModelDownload" - const val CHANNEL_ID = "model_download" - const val NOTIFICATION_ID = 42042 - - // ~200 MB to be safe (models are ~147 MB but we need temp space) - const val MINIMUM_REQUIRED_BYTES = 200L * 1024 * 1024 - } -} - -/** - * Extension function to start model download. - */ -fun WorkManager.downloadModels() { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresStorageNotLow(true) - .build() - - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(ModelDownloadWorker.TAG) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30_000L, java.util.concurrent.TimeUnit.MILLISECONDS) - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - } - } - .build() - - enqueueUniqueWork( - ModelDownloadWorker.WORK_NAME, - ExistingWorkPolicy.KEEP, - request - ) -} - -/** - * Extension function to cancel model download. - */ -fun WorkManager.cancelModelDownload() { - cancelUniqueWork(ModelDownloadWorker.WORK_NAME) -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/RotateMediaWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/RotateMediaWorker.kt index d966a526f8..04205744f4 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/RotateMediaWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/RotateMediaWorker.kt @@ -6,8 +6,8 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.os.Build import android.provider.MediaStore +import android.util.Log import androidx.annotation.IntDef import androidx.core.net.toUri import androidx.hilt.work.HiltWorker @@ -17,28 +17,38 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.awxkee.jxlcoder.JxlCoder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.core.util.MAX_ENCODED_MEDIA_BYTES +import com.dot.gallery.core.util.SizeLimitExceededException +import com.dot.gallery.core.util.SizeLimitedInputStream +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.github.panpf.sketch.util.rotate -import com.radzivon.bartoshyk.avif.coder.HeifCoder import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.Locale +import java.util.UUID +import kotlin.math.min +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import java.util.UUID -fun WorkManager.rotateImage( - media: Media, - degrees: Int -): UUID { +private const val TAG = "RotateMediaWorker" + +fun WorkManager.rotateImage(media: Media, degrees: Int): UUID { val work = OneTimeWorkRequestBuilder() .setInputData( workDataOf( RotateMediaWorker.KEY_MEDIA_URI to media.getUri().toString(), RotateMediaWorker.KEY_ROTATION_DEGREES to degrees, RotateMediaWorker.KEY_MIME_TYPE to media.mimeType, - ) + ), ) .build() enqueue(work) @@ -48,165 +58,302 @@ fun WorkManager.rotateImage( @HiltWorker class RotateMediaWorker @AssistedInject constructor( @Assisted private val appContext: Context, - @Assisted params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - - private val cr: ContentResolver = appContext.contentResolver + @Assisted parameters: WorkerParameters, +) : CoroutineWorker(appContext, parameters) { - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val uriStr = inputData.getString(KEY_MEDIA_URI) - ?: return@withContext failure("Missing media Uri") - val sourceUri = uriStr.toUri() - val degrees = (inputData.getInt(KEY_ROTATION_DEGREES, 0) + 360) % 360 - if (degrees % 90 != 0) return@withContext failure("Rotation must be multiple of 90") - if (degrees == 0) return@withContext success("No rotation requested") + private val contentResolver: ContentResolver = appContext.contentResolver - update(Status.STARTED, "Begin") - val mime = inputData.getString(KEY_MIME_TYPE) - ?: (cr.getType(sourceUri) ?: "image/jpeg") + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + val sourceUri = inputData.getString(KEY_MEDIA_URI)?.toUri() + ?: return@withContext failure( + message = "Missing media URI", + reason = FAILURE_REASON_GENERIC, + ) + val degrees = inputData.getInt(KEY_ROTATION_DEGREES, 0).mod(other = FULL_ROTATION_DEGREES) + if (degrees % RIGHT_ANGLE_DEGREES != 0) { + return@withContext failure( + message = "Rotation must be a multiple of 90 degrees", + reason = FAILURE_REASON_GENERIC, + ) + } + if (degrees == 0) { + return@withContext success(message = "No rotation requested") + } - try { - update(Status.DECODING, "Decoding original") - val original = decodeFullResolution(sourceUri, mime) - ?: return@withContext failure("Decode failed") - - update(Status.ROTATING, "Applying rotation=$degrees") - val rotated = original.rotate(degrees) - if (rotated !== original) original.recycle() - - update(Status.SAVING, "Saving") - val format = chooseCompressFormat(mime) - val saved = saveRotatedInPlace( - sourceUri = sourceUri, - rotated = rotated, - format = format, - quality = 95 + val declaredMimeType = normalizedMimeType( + mimeType = inputData.getString(KEY_MIME_TYPE) ?: contentResolver.getType(sourceUri), ) - rotated.recycle() - if (!saved) return@withContext failure("Save failed") - - update(Status.COMPLETED, "Done") - success("Rotation applied") - } catch (oom: OutOfMemoryError) { - oom.printStackTrace() - failure("OOM while rotating") - } catch (e: Exception) { - failure("Error: ${e.message}") + + try { + update(status = Status.STARTED, message = "Begin") + validateSourceSize(uri = sourceUri) + val imageHeader = readImageHeader(uri = sourceUri) + ?: return@withContext failure( + message = "Failed to read image dimensions", + reason = FAILURE_REASON_GENERIC, + ) + val compressFormat = compressFormatFor( + mimeType = imageHeader.mimeType ?: declaredMimeType, + ) ?: return@withContext failure( + message = "Unsupported image format", + reason = FAILURE_REASON_UNSUPPORTED_FORMAT, + ) + validateRotationMemory(imageHeader = imageHeader) + + update(status = Status.DECODING, message = "Decoding original") + val original = decodeFullResolution(uri = sourceUri) + ?: return@withContext failure( + message = "Failed to decode image", + reason = FAILURE_REASON_GENERIC, + ) + + update(status = Status.ROTATING, message = "Applying rotation=$degrees") + val rotated = original.rotate(degrees) + if (rotated !== original) { + original.recycle() + } + + try { + update(status = Status.SAVING, message = "Saving") + replaceWithRotatedImage( + sourceUri = sourceUri, + rotated = rotated, + compressFormat = compressFormat, + ) + } finally { + rotated.recycle() + } + + update(status = Status.COMPLETED, message = "Done") + success(message = "Rotation applied") + } catch (exception: ImageTooLargeException) { + Log.w(TAG, "Refusing unsafe full-resolution rotation for $sourceUri", exception) + failure( + message = exception.message ?: "Image is too large to rotate safely", + reason = FAILURE_REASON_TOO_LARGE, + ) + } catch (exception: SizeLimitExceededException) { + Log.w(TAG, "Refusing oversized encoded image $sourceUri", exception) + failure( + message = exception.message ?: "Image is too large to rotate safely", + reason = FAILURE_REASON_TOO_LARGE, + ) + } catch (exception: CancellationException) { + throw exception + } catch (error: OutOfMemoryError) { + Log.e(TAG, "Out of memory while rotating $sourceUri", error) + failure( + message = "Image is too large to rotate safely", + reason = FAILURE_REASON_TOO_LARGE, + ) + } catch (exception: Exception) { + Log.w(TAG, "Failed to rotate $sourceUri", exception) + failure( + message = exception.message ?: "Rotation failed", + reason = FAILURE_REASON_GENERIC, + ) + } + } + } + + private fun normalizedMimeType(mimeType: String?): String? { + return mimeType + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.ROOT) + } + + private fun compressFormatFor(mimeType: String?): Bitmap.CompressFormat? { + return when (mimeType) { + "image/jpeg", "image/jpg" -> Bitmap.CompressFormat.JPEG + "image/png" -> Bitmap.CompressFormat.PNG + "image/webp" -> Bitmap.CompressFormat.WEBP_LOSSLESS + else -> null + } + } + + private fun validateSourceSize(uri: Uri) { + val sourceSize = contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.length.takeIf { length -> length >= 0L } + } + when { + sourceSize != null && sourceSize > MAX_ENCODED_MEDIA_BYTES -> { + throw ImageTooLargeException("Encoded image exceeds the safe size limit") + } + + sourceSize == null -> { + openLimitedInputStream(uri = uri).use { inputStream -> + val buffer = ByteArray(STREAM_BUFFER_BYTES) + while (inputStream.read(buffer) != -1) { + // Read to EOF so the size-limited stream can enforce the cap. + } + } + } + } + } + + private fun readImageHeader(uri: Uri): RotationImageHeader? { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + openLimitedInputStream(uri = uri).use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, options) } + return RotationImageHeader( + width = options.outWidth, + height = options.outHeight, + mimeType = normalizedMimeType(mimeType = options.outMimeType), + ).takeIf { imageHeader -> imageHeader.width > 0 && imageHeader.height > 0 } } - private suspend fun saveRotatedInPlace( + private fun validateRotationMemory(imageHeader: RotationImageHeader) { + val bitmapBytes = imageHeader.width.toLong() * imageHeader.height.toLong() * + ARGB_BYTES_PER_PIXEL + val requiredBytes = bitmapBytes * ROTATION_BITMAP_COUNT + val memoryBudget = min( + MAX_ROTATION_MEMORY_BYTES, + Runtime.getRuntime().maxMemory() / HEAP_BUDGET_DIVISOR, + ) + if (bitmapBytes <= 0L || requiredBytes > memoryBudget) { + throw ImageTooLargeException("Full-resolution rotation exceeds the safe memory budget") + } + } + + private fun decodeFullResolution(uri: Uri): Bitmap? { + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + inMutable = false + } + return openLimitedInputStream(uri = uri).use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, options) + } + } + + private fun openLimitedInputStream(uri: Uri): InputStream { + val inputStream = contentResolver.openInputStream(uri) + ?: throw IOException("Failed to open source image") + return SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = MAX_ENCODED_MEDIA_BYTES, + ) + } + + private suspend fun replaceWithRotatedImage( sourceUri: Uri, rotated: Bitmap, - format: Bitmap.CompressFormat, - quality: Int - ): Boolean = withContext(Dispatchers.IO) { + compressFormat: Bitmap.CompressFormat, + ) { + val encodedFile = File.createTempFile("rotation_encoded_", ".tmp", appContext.cacheDir) + val backupFile = File.createTempFile("rotation_backup_", ".tmp", appContext.cacheDir) + var sourceWriteStarted = false try { - // Overwrite the original file via its Uri - cr.openOutputStream(sourceUri, "wt")?.use { out -> - if (!rotated.compress(format, quality, out)) { - throw RuntimeException("Compression failed") + encodedFile.outputStream().buffered().use { outputStream -> + val compressed = rotated.compress(compressFormat, COMPRESSION_QUALITY, outputStream) + if (!compressed) { + throw IOException("Failed to encode rotated image") + } + } + openLimitedInputStream(uri = sourceUri).use { inputStream -> + backupFile.outputStream().buffered().use { outputStream -> + copyStream(inputStream = inputStream, outputStream = outputStream) } - } ?: throw RuntimeException("Stream failed") + } - // Touch date_modified so MediaStore picks up the change - val values = ContentValues().apply { - put( - MediaStore.MediaColumns.DATE_MODIFIED, - System.currentTimeMillis() / 1000 - ) + sourceWriteStarted = true + writeFileToUri(file = encodedFile, destinationUri = sourceUri) + touchMediaStore(uri = sourceUri) + } catch (exception: CancellationException) { + if (sourceWriteStarted) { + restoreBackup(sourceUri = sourceUri, backupFile = backupFile) } - cr.update(sourceUri, values, null, null) - true - } catch (e: Exception) { - e.printStackTrace() - false + throw exception + } catch (exception: Exception) { + if (sourceWriteStarted) { + restoreBackup(sourceUri = sourceUri, backupFile = backupFile) + } + throw exception + } finally { + encodedFile.delete() + backupFile.delete() } } - private fun decodeFullResolution(uri: Uri, mime: String): Bitmap? { - val lower = mime.lowercase() - return cr.openInputStream(uri)?.use { input -> - when { - lower.contains("jxl") -> { - val bytes = input.readBytes() - val size = JxlCoder.getSize(bytes) ?: return null - JxlCoder.decodeSampled(bytes, size.width, size.height) - } - - lower.contains("heic") || lower.contains("heif") || lower.contains("avif") || lower.contains( - "avis" - ) -> { - val bytes = input.readBytes() - val coder = HeifCoder() - val size = coder.getSize(bytes) ?: return null - coder.decodeSampled(bytes, size.width, size.height) - } + private suspend fun restoreBackup(sourceUri: Uri, backupFile: File) { + withContext(NonCancellable + Dispatchers.IO) { + try { + writeFileToUri(file = backupFile, destinationUri = sourceUri) + } catch (exception: Exception) { + Log.e(TAG, "Failed to restore original image after rotation failure", exception) + } + } + } - else -> { - val opts = BitmapFactory.Options().apply { - inPreferredConfig = Bitmap.Config.ARGB_8888 - inMutable = false - } - BitmapFactory.decodeStream(input, null, opts) - } + private suspend fun writeFileToUri(file: File, destinationUri: Uri) { + val outputStream = contentResolver.openOutputStream(destinationUri, "wt") + ?: throw IOException("Failed to open destination image") + file.inputStream().buffered().use { inputStream -> + outputStream.buffered().use { destinationStream -> + copyStream(inputStream = inputStream, outputStream = destinationStream) } } } - private fun chooseCompressFormat(mime: String): Bitmap.CompressFormat { - val m = mime.lowercase() - return when { - m.contains("jpeg") || m.contains("jpg") -> Bitmap.CompressFormat.JPEG - m.contains("png") -> Bitmap.CompressFormat.PNG - m.contains("webp") -> { - @Suppress("DEPRECATION") - Bitmap.CompressFormat.WEBP + private suspend fun copyStream(inputStream: InputStream, outputStream: OutputStream) { + val buffer = ByteArray(STREAM_BUFFER_BYTES) + while (true) { + currentCoroutineContext().ensureActive() + val readBytes = inputStream.read(buffer) + if (readBytes == -1) { + break } + outputStream.write(buffer, 0, readBytes) + } + outputStream.flush() + } - else -> Bitmap.CompressFormat.PNG + private fun touchMediaStore(uri: Uri) { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / MILLIS_PER_SECOND) } + contentResolver.update(uri, values, null, null) } - private suspend fun update(@Status status: Int, msg: String) { - setProgress(workDataOf(KEY_STATUS to status, KEY_MESSAGE to msg)) + private suspend fun update(@Status status: Int, message: String) { + setProgress(workDataOf(KEY_STATUS to status, KEY_MESSAGE to message)) } - private fun success(msg: String): Result = - Result.success( + private fun success(message: String): Result { + return Result.success( Data.Builder() .putInt(KEY_STATUS, Status.COMPLETED) - .putString(KEY_MESSAGE, msg) - .build() + .putString(KEY_MESSAGE, message) + .build(), ) + } - private fun failure(msg: String): Result = - Result.failure( + private fun failure(message: String, @FailureReason reason: Int): Result { + return Result.failure( Data.Builder() .putInt(KEY_STATUS, Status.FAILED) - .putString(KEY_MESSAGE, msg) - .build() + .putString(KEY_MESSAGE, message) + .putInt(KEY_FAILURE_REASON, reason) + .build(), ) - - companion object { - const val KEY_MEDIA_URI = "media_uri" - const val KEY_ROTATION_DEGREES = "rotation_degrees" - const val KEY_MIME_TYPE = "mime_type" - - const val KEY_STATUS = "status" - const val KEY_MESSAGE = "message" } + private class ImageTooLargeException(message: String) : IOException(message) + @IntDef( Status.STARTED, Status.DECODING, Status.ROTATING, Status.SAVING, Status.COMPLETED, - Status.FAILED + Status.FAILED, ) @Retention(AnnotationRetention.SOURCE) - annotation class Status { + private annotation class Status { companion object { const val STARTED = 0 const val DECODING = 1 @@ -216,4 +363,35 @@ class RotateMediaWorker @AssistedInject constructor( const val FAILED = 5 } } + + @IntDef( + FAILURE_REASON_GENERIC, + FAILURE_REASON_TOO_LARGE, + FAILURE_REASON_UNSUPPORTED_FORMAT, + ) + @Retention(AnnotationRetention.SOURCE) + private annotation class FailureReason + + companion object { + internal const val KEY_MEDIA_URI = "media_uri" + internal const val KEY_ROTATION_DEGREES = "rotation_degrees" + internal const val KEY_MIME_TYPE = "mime_type" + internal const val KEY_FAILURE_REASON = "failure_reason" + + internal const val FAILURE_REASON_GENERIC = 0 + internal const val FAILURE_REASON_TOO_LARGE = 1 + internal const val FAILURE_REASON_UNSUPPORTED_FORMAT = 2 + + private const val ARGB_BYTES_PER_PIXEL = 4L + private const val COMPRESSION_QUALITY = 95 + private const val FULL_ROTATION_DEGREES = 360 + private const val HEAP_BUDGET_DIVISOR = 4L + private const val KEY_MESSAGE = "message" + private const val KEY_STATUS = "status" + private const val MAX_ROTATION_MEMORY_BYTES = 96L * 1024L * 1024L + private const val MILLIS_PER_SECOND = 1_000L + private const val RIGHT_ANGLE_DEGREES = 90 + private const val ROTATION_BITMAP_COUNT = 2L + private const val STREAM_BUFFER_BYTES = 64 * 1024 + } } diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/RotationImageHeader.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/RotationImageHeader.kt new file mode 100644 index 0000000000..17d08c8785 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/RotationImageHeader.kt @@ -0,0 +1,7 @@ +package com.dot.gallery.core.workers + +internal data class RotationImageHeader( + val width: Int, + val height: Int, + val mimeType: String?, +) diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorker.kt index daf03a00d1..38dc74594f 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorker.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorker.kt @@ -1,105 +1,339 @@ package com.dot.gallery.core.workers import android.content.Context -import android.graphics.ColorSpace -import androidx.compose.ui.util.fastForEachIndexed import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import androidx.work.workDataOf -import com.dot.gallery.BuildConfig -import com.dot.gallery.core.ml.ModelManager -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.search.helpers.SearchVisionHelper -import com.dot.gallery.feature_node.presentation.search.util.centerCrop +import com.dot.gallery.core.ml.ImageEmbeddingGenerator +import com.dot.gallery.core.ml.ImageEmbeddingSession +import com.dot.gallery.core.ml.ModelStatus +import com.dot.gallery.core.sandbox.MediaPreviewDecoder +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.util.printInfo import com.dot.gallery.feature_node.presentation.util.printWarning -import com.github.panpf.sketch.asBitmapOrNull -import com.github.panpf.sketch.decode.BitmapColorSpace -import com.github.panpf.sketch.request.ImageRequest -import com.github.panpf.sketch.sketch import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.isActive +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first import kotlinx.coroutines.yield @HiltWorker -class SearchIndexerUpdaterWorker @AssistedInject constructor( +class SearchIndexerUpdaterWorker @AssistedInject internal constructor( private val repository: MediaRepository, - private val modelManager: ModelManager, - @Assisted private val appContext: Context, - @Assisted workerParams: WorkerParameters + private val analysisRepository: AiMediaAnalysisRepository, + private val embeddingGenerator: ImageEmbeddingGenerator, + private val previewDecoder: MediaPreviewDecoder, + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, ) : CoroutineWorker(appContext, workerParams) { - private val visionHelper by lazy { SearchVisionHelper(modelManager) } + override suspend fun doWork(): Result { + return try { + indexMedia() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + printWarning("SearchIndexerUpdaterWorker failed with exception: ${exception.message}") + Result.failure() + } + } - override suspend fun doWork(): Result = runCatching { - setProgress(workDataOf("progress" to -1f)) - delay(2000) - if (!BuildConfig.ENABLE_INDEXING) return Result.success() - if (!modelManager.isReady) { - printInfo("ML models not installed, skipping indexing") - return Result.success() + private suspend fun indexMedia(): Result { + setProgress(workDataOf(KEY_PROGRESS to -1f, KEY_STAGE to STAGE_INDEXING)) + if (!analysisRepository.getPreferences().analysisEnabled) { + return Result.success(workDataOf(KEY_CHANGED_COUNT to 0)) } - if (!currentCoroutineContext().isActive) return Result.success() - printInfo("Starting indexing media items") - val media = repository.getCompleteMedia().map { it.data ?: emptyList() }.firstOrNull() - val records = repository.getImageEmbeddings().firstOrNull() - val toBeIndexed = media?.filter { mediaItem -> - records?.none { it.id == mediaItem.id } ?: true - } ?: emptyList() - if (toBeIndexed.isEmpty()) { - printInfo("No media items to index") - return Result.success() + val modelStatus = embeddingGenerator.status.first { status -> status != ModelStatus.CHECKING } + if (modelStatus != ModelStatus.READY) { + printInfo("ML models unavailable, skipping indexing") + return Result.success(workDataOf(KEY_CHANGED_COUNT to 0)) } - printInfo("Found ${toBeIndexed.size} media items to index") - setProgress(workDataOf("progress" to 0f)) - visionHelper.setupVisionSession().use { session -> - val total = toBeIndexed.size - toBeIndexed.fastForEachIndexed { index, mediaItem -> - if (!currentCoroutineContext().isActive || isStopped) return@use - val startMillis = System.currentTimeMillis() - val pct = if (total <= 1) 100f else ((index.toFloat() / (total - 1).toFloat()) * 100f) - setProgress(workDataOf("progress" to pct)) - val request = ImageRequest(appContext, mediaItem.getUri().toString()) { - colorSpace(BitmapColorSpace(ColorSpace.Named.SRGB)) - size(224, 224) + + val mappingReconciliation = removeMissingCategoryMappings() + val indexedChangeCount = reconcileAndIndexMedia( + totalMediaCount = mappingReconciliation.totalMediaCount, + ) + setProgress(workDataOf(KEY_PROGRESS to 100f, KEY_STAGE to STAGE_INDEXING)) + return Result.success( + workDataOf( + KEY_CHANGED_COUNT to mappingReconciliation.removedCount + indexedChangeCount, + ), + ) + } + + private suspend fun removeMissingCategoryMappings(): CategoryMappingReconciliation { + val mediaReader = mediaPageReader() + val classifiedIdReader = PagedItemReader( + pageSize = DATABASE_PAGE_SIZE, + getId = { mediaId -> mediaId }, + loadPage = analysisRepository::getClassifiedMediaIdPage, + ) + var mediaItem = mediaReader.next() + var classifiedMediaId = classifiedIdReader.next() + val missingMediaIds = mutableSetOf() + var removedCount = 0 + var totalMediaCount = 0 + while (classifiedMediaId != null) { + currentCoroutineContext().ensureActive() + while (mediaItem != null && mediaItem.id < classifiedMediaId) { + mediaItem = mediaReader.next() + totalMediaCount++ + } + when { + mediaItem?.id == classifiedMediaId -> { + mediaItem = mediaReader.next() + totalMediaCount++ + classifiedMediaId = classifiedIdReader.next() + } + + else -> { + missingMediaIds += classifiedMediaId + removedCount++ + classifiedMediaId = classifiedIdReader.next() + if (missingMediaIds.size == DATABASE_PAGE_SIZE) { + analysisRepository.removeMediaData(mediaIds = missingMediaIds.toSet()) + missingMediaIds.clear() + } } - val result = appContext.sketch.execute(request) - val bitmap = result.image?.asBitmapOrNull() - if (bitmap != null) { - val rawBitmap = centerCrop(bitmap, 224) - val embedding = visionHelper.getImageEmbedding(session, rawBitmap) - printInfo("Indexed media item $index/${total - 1} in ${System.currentTimeMillis() - startMillis} ms") - repository.addImageEmbedding( - ImageEmbedding( - id = mediaItem.id, - date = mediaItem.timestamp, - embedding = embedding + } + } + if (missingMediaIds.isNotEmpty()) { + analysisRepository.removeMediaData(mediaIds = missingMediaIds.toSet()) + } + while (mediaItem != null) { + mediaItem = mediaReader.next() + totalMediaCount++ + } + return CategoryMappingReconciliation( + removedCount = removedCount, + totalMediaCount = totalMediaCount, + ) + } + + private suspend fun reconcileAndIndexMedia(totalMediaCount: Int): Int { + val mediaReader = mediaPageReader() + val stampReader = PagedItemReader( + pageSize = DATABASE_PAGE_SIZE, + getId = { stamp -> stamp.id }, + loadPage = repository::getImageEmbeddingStampPage, + ) + var mediaItem = mediaReader.next() + var stamp = stampReader.next() + val removedIds = mutableSetOf() + val pendingMedia = mutableListOf() + var embeddingSession: ImageEmbeddingSession? = null + var changedCount = 0 + var scannedMediaCount = 0 + + suspend fun flushRemovedIds() { + if (removedIds.isNotEmpty()) { + analysisRepository.removeMediaData(mediaIds = removedIds.toSet()) + removedIds.clear() + } + } + + fun getOrOpenEmbeddingSession(): ImageEmbeddingSession { + val currentSession = embeddingSession + if (currentSession != null) { + return currentSession + } + return embeddingGenerator.openSession().also { openedSession -> + embeddingSession = openedSession + } + } + + suspend fun flushPendingMedia(): Boolean { + if (pendingMedia.isEmpty()) { + return true + } + val changedIds = pendingMedia + .filterTo(mutableListOf()) { item -> item.invalidatesExistingData } + .mapTo(mutableSetOf()) { item -> item.media.id } + if (changedIds.isNotEmpty()) { + analysisRepository.invalidateGeneratedData(mediaIds = changedIds) + } + for (item in pendingMedia) { + val indexed = indexMediaItem( + mediaItem = item.media, + getSession = ::getOrOpenEmbeddingSession, + ) + if (!indexed) { + pendingMedia.clear() + return false + } + } + pendingMedia.clear() + return true + } + + try { + while (mediaItem != null || stamp != null) { + currentCoroutineContext().ensureActive() + when { + mediaItem == null -> { + removedIds += requireNotNull(stamp).id + changedCount++ + stamp = stampReader.next() + } + + stamp == null || mediaItem.id < stamp.id -> { + pendingMedia += PendingMedia( + media = mediaItem, + invalidatesExistingData = false, ) - ) - } else { - printInfo("Failed to decode bitmap for media: ${mediaItem.id} at ${mediaItem.getUri()}") + changedCount++ + mediaItem = mediaReader.next() + scannedMediaCount++ + } + + stamp.id < mediaItem.id -> { + removedIds += stamp.id + changedCount++ + stamp = stampReader.next() + } + + else -> { + if (mediaItem.timestamp != stamp.date) { + pendingMedia += PendingMedia( + media = mediaItem, + invalidatesExistingData = true, + ) + changedCount++ + } + mediaItem = mediaReader.next() + stamp = stampReader.next() + scannedMediaCount++ + } + } + if (scannedMediaCount > 0 && scannedMediaCount % MEDIA_PAGE_SIZE == 0) { + val progress = when { + totalMediaCount == 0 -> 99f + else -> (scannedMediaCount.toFloat() / totalMediaCount.toFloat() * 99f) + .coerceIn(0f, 99f) + } + setProgress(workDataOf(KEY_PROGRESS to progress, KEY_STAGE to STAGE_INDEXING)) + } + if (removedIds.size == DATABASE_PAGE_SIZE) { + flushRemovedIds() + } + if (pendingMedia.size == INDEXING_PAGE_SIZE && !flushPendingMedia()) { + flushRemovedIds() + return changedCount } - yield() } + flushRemovedIds() + flushPendingMedia() + return changedCount + } finally { + embeddingSession?.close() } - if (currentCoroutineContext().isActive) { - printInfo("Indexing completed for ${toBeIndexed.size} media items") - setProgress(workDataOf("progress" to 100f)) - } else { - printWarning("Indexing cancelled before completion") + } + + private suspend fun indexMediaItem( + mediaItem: Media.UriMedia, + getSession: () -> ImageEmbeddingSession, + ): Boolean { + currentCoroutineContext().ensureActive() + if (!analysisRepository.getPreferences().analysisEnabled) { + return false + } + val bitmap = previewDecoder.decode( + uri = mediaItem.getUri(), + mimeType = mediaItem.mimeType, + isVideo = mediaItem.isVideo, + ) ?: return true + try { + currentCoroutineContext().ensureActive() + if (!analysisRepository.getPreferences().analysisEnabled) { + return false + } + val embedding = getSession().generate(bitmap = bitmap) + currentCoroutineContext().ensureActive() + if (!analysisRepository.getPreferences().analysisEnabled) { + return false + } + repository.addImageEmbedding( + imageEmbedding = ImageEmbedding( + id = mediaItem.id, + date = mediaItem.timestamp, + embedding = embedding, + ), + ) + } finally { + bitmap.recycle() } - return Result.success() - }.getOrElse { exception -> - printWarning("SearchIndexerUpdaterWorker failed with exception: ${exception.message}") - return Result.failure() + yield() + return true + } + + private fun mediaPageReader(): PagedItemReader { + return PagedItemReader( + pageSize = MEDIA_PAGE_SIZE, + getId = { mediaItem -> mediaItem.id }, + loadPage = repository::getCompleteMediaPage, + ) + } + + companion object { + internal const val KEY_CHANGED_COUNT = "changed_count" + internal const val KEY_PROGRESS = "progress" + internal const val KEY_STAGE = "stage" + internal const val STAGE_INDEXING = "indexing" + internal const val TAG = "SearchIndexerUpdater" + private const val DATABASE_PAGE_SIZE = 900 + private const val INDEXING_PAGE_SIZE = 32 + private const val MEDIA_PAGE_SIZE = 256 } -} \ No newline at end of file + private data class PendingMedia( + val media: Media.UriMedia, + val invalidatesExistingData: Boolean, + ) + + private data class CategoryMappingReconciliation( + val removedCount: Int, + val totalMediaCount: Int, + ) + + private class PagedItemReader( + private val pageSize: Int, + private val getId: (T) -> Long, + private val loadPage: suspend (afterId: Long, limit: Int) -> List, + ) { + private var afterId = Long.MIN_VALUE + private var currentPage = emptyList() + private var currentIndex = 0 + private var exhausted = false + + suspend fun next(): T? { + if (currentIndex == currentPage.size && !loadNextPage()) { + return null + } + return currentPage[currentIndex++] + } + + private suspend fun loadNextPage(): Boolean { + if (exhausted) { + return false + } + currentPage = loadPage(afterId, pageSize) + currentIndex = 0 + if (currentPage.isEmpty()) { + exhausted = true + return false + } + val lastId = getId(currentPage.last()) + check(lastId > afterId) { "Keyset page did not advance" } + afterId = lastId + return true + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/TempVaultCleanupWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/TempVaultCleanupWorker.kt deleted file mode 100644 index c8b242b24c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/TempVaultCleanupWorker.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.dot.gallery.core.workers - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import com.dot.gallery.feature_node.presentation.util.printDebug -import dagger.hilt.android.EntryPointAccessors -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import java.io.File -import java.util.concurrent.TimeUnit - -/** - * Periodically deletes orphaned decrypted temp files created for large encrypted media streaming. - * Files targeted: cacheDir/vault_stream_*.tmp older than [maxAgeHours]. - */ -@HiltWorker -class TempVaultCleanupWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted params: WorkerParameters -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result = runCatching { - val maxAgeHours = inputData.getLong(KEY_MAX_AGE_HOURS, DEFAULT_MAX_AGE_HOURS) - val cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(maxAgeHours) - val cacheDir = appContext.cacheDir ?: return@runCatching Result.success() - var deletedCount = 0 - cacheDir.listFiles()?.forEach { f -> - if (f.isFile && f.name.startsWith(TEMP_PREFIX) && f.name.endsWith(".tmp")) { - if (f.lastModified() < cutoff) { - if (f.delete()) deletedCount++ - } - } - } - // Sidecar metadata purge ( >7 days ) - val sidecarDir = File(cacheDir, "meta_sidecar") - if (sidecarDir.isDirectory) { - val metaCutoff = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7) - sidecarDir.listFiles()?.forEach { f -> - if (f.isFile && f.lastModified() < metaCutoff) { - if (f.delete()) deletedCount++ - } - } - } - printDebug("TempVaultCleanupWorker removed $deletedCount stale temp/meta files") - runCatching { - val ep = EntryPointAccessors.fromApplication(appContext, com.dot.gallery.core.metrics.MetricsCollectorEntryPoint::class.java) - val snap = ep.metrics().snapshot() - printDebug("Metrics: decrypt=${snap.decryptInvocations} waiters=${snap.decryptCoalescedWaiters} lruHit=${snap.lruHits}/${snap.lruHits + snap.lruMisses} sidecar R/W=${snap.sidecarReads}/${snap.sidecarWrites}") - } - Result.success() - }.getOrElse { e -> - printDebug("TempVaultCleanupWorker failed: ${e.message}") - Result.failure() - } - - companion object { - private const val TEMP_PREFIX = "vault_stream_" - private const val DEFAULT_MAX_AGE_HOURS = 12L - private const val UNIQUE_WORK = "TempVaultCleanup" - const val KEY_MAX_AGE_HOURS = "maxAgeHours" - - fun schedule(workManager: WorkManager, maxAgeHours: Long = DEFAULT_MAX_AGE_HOURS) { - val req = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) - .addTag(UNIQUE_WORK) - .setInputData( - androidx.work.workDataOf( - KEY_MAX_AGE_HOURS to maxAgeHours - ) - ) - .build() - workManager.enqueueUniquePeriodicWork( - UNIQUE_WORK, - ExistingPeriodicWorkPolicy.UPDATE, - req - ) - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/core/workers/VaultOperationWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/workers/VaultOperationWorker.kt deleted file mode 100644 index a8c7641990..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/workers/VaultOperationWorker.kt +++ /dev/null @@ -1,267 +0,0 @@ -package com.dot.gallery.core.workers - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.pm.ServiceInfo -import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.net.toUri -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.dot.gallery.R -import com.dot.gallery.core.util.ProgressThrottler -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.getUri -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import dagger.hilt.android.EntryPointAccessors -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.util.UUID - -/** - * Unified worker for vault related operations (encrypt/add to vault, restore/decrypt, hide/delete encrypted). - * Also re-used for simple hide from main timeline by moving into a vault or performing repository mutation. - */ -@HiltWorker -class VaultOperationWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted params: WorkerParameters, - private val repository: MediaRepository, -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val op = inputData.getString(KEY_OPERATION) ?: return@withContext Result.failure() - val mediaJson = inputData.getString(KEY_MEDIA_URIS) ?: return@withContext Result.failure() - val inputUris = Json.decodeFromString>(mediaJson).map { it.toUri() } - val requestDelete = inputData.getBoolean(KEY_DELETE_ORIGINALS, false) - val resolver = appContext.contentResolver - var skipped = 0 - val mediaUris = inputUris.filter { uri -> - try { - // Lightweight existence probe: openAssetFileDescriptor with mode 'r' - resolver.openAssetFileDescriptor(uri, "r")?.use { } - true - } catch (_: Exception) { - skipped++ - false - } - } - if (skipped > 0) { - try { - val ep = EntryPointAccessors.fromApplication(appContext, com.dot.gallery.core.metrics.MetricsCollectorEntryPoint::class.java) - ep.metrics().incSidecarRead() // reuse counter slot; ideally add dedicated skip metric later - } catch (_: Throwable) {} - com.dot.gallery.feature_node.presentation.util.printDebug("VaultOperationWorker skipped $skipped missing URIs") - } - if (mediaUris.isEmpty()) { - // Nothing to do; treat as success to avoid retry loops due to deleted items. - return@withContext Result.success() - } - - // Set initial foreground notification - setForeground(createForegroundInfo(progress = 0f, operation = op)) - - val processed = mutableListOf() - val result = when (op) { - OP_ENCRYPT, OP_HIDE -> { - val vaultJson = - inputData.getString(KEY_VAULT) ?: return@withContext Result.failure() - val vault = Json.decodeFromString(vaultJson) - val mediaList = - repository.getMediaListByUris(mediaUris, reviewMode = false, onlyMatching = true).firstOrNull()?.data - ?: return@withContext Result.failure() - val total = mediaList.size.coerceAtLeast(1) - if (mediaList.isEmpty()) return@withContext Result.success() - mediaList.forEachIndexed { index, media -> - if (!currentCoroutineContext().isActive || isStopped) return@withContext Result.success() - repository.addMedia(vault, media) - processed += media.getUri() - updateProgress(completed = index + 1, total = total, operation = op) - } - Result.success() - } - - OP_DECRYPT -> { - val vaultJson = - inputData.getString(KEY_VAULT) ?: return@withContext Result.failure() - val vault = Json.decodeFromString(vaultJson) - - val allVaultMedia = repository.getEncryptedMedia(vault).firstOrNull()?.data - ?: return@withContext Result.failure() - - val mediaList = allVaultMedia.filter { media -> - mediaUris.contains(media.uri) - } - - val total = mediaList.size.coerceAtLeast(1) - if (mediaList.isEmpty()) return@withContext Result.success() - mediaList.forEachIndexed { index, media -> - if (!currentCoroutineContext().isActive || isStopped) return@withContext Result.success() - repository.restoreMedia(vault, media) - updateProgress(completed = index + 1, total = total, operation = op) - } - Result.success() - } - - OP_MIGRATE -> { - repository.migrateVault() - Result.success() - } - - else -> Result.failure() - } - - if (result == Result.success() && requestDelete && processed.isNotEmpty()) { - // We cannot silently delete without user consent; expose URIs back so UI can launch a delete request. - // Provide processed URI list as JSON in output so caller can trigger permission flow exactly once. - return@withContext Result.success( - workDataOf( - KEY_LEFTOVER_URIS to Json.encodeToString(processed.map { it.toString() }) - ) - ) - } - result - } - - /** - * Update progress as a bounded percentage (0f..100f). `completed` is the count of items - * already processed (1-based when called after processing an item). This prevents values - * exceeding 100 when the underlying list size differs from the original URI list length. - */ - private val throttler = ProgressThrottler() - private suspend fun updateProgress(completed: Int, total: Int, operation: String) { - val safeTotal = total.coerceAtLeast(1) - val raw = (completed.toFloat() / safeTotal.toFloat()) * PROGRESS_MAX - val pct = raw.coerceIn(0f, PROGRESS_MAX) - val pctInt = pct.toInt() - throttler.emit(pctInt) { - setProgress(workDataOf(KEY_PROGRESS to it)) - setForeground(createForegroundInfo(pct, operation)) - } - } - - private fun createForegroundInfo(progress: Float, operation: String): ForegroundInfo { - val channelId = ensureChannel() - val title = when (operation) { - OP_ENCRYPT, OP_HIDE -> appContext.getString(R.string.vault_encrypt_notification_title) - OP_DECRYPT -> appContext.getString(R.string.vault_decrypt_notification_title) - else -> appContext.getString(R.string.vault_encrypt_notification_title) - } - val text = appContext.getString(R.string.vault_operation_in_progress, progress.toInt()) - val notif: Notification = NotificationCompat.Builder(appContext, channelId) - .setSmallIcon(R.drawable.ic_vault) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setProgress(100, progress.toInt(), false) - .setContentTitle(title) - .setContentText(text) - .build() - - val fgsType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } - return ForegroundInfo(NOTIFICATION_ID, notif, fgsType) - } - - private fun ensureChannel(): String { - val channelId = CHANNEL_ID - val nm = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (nm.getNotificationChannel(channelId) == null) { - nm.createNotificationChannel( - NotificationChannel( - channelId, - appContext.getString(R.string.vault_notification_channel_name), - NotificationManager.IMPORTANCE_LOW - ).apply { - description = - appContext.getString(R.string.vault_notification_channel_description) - } - ) - } - return channelId - } - - companion object { - private const val CHANNEL_ID = "vault_operations" - private const val NOTIFICATION_ID = 77 - private const val PROGRESS_MAX = 100f - const val KEY_OPERATION = "operation" - const val KEY_VAULT = "vault" - const val KEY_MEDIA_URIS = "mediaUris" - const val KEY_PROGRESS = "progress" - const val KEY_DELETE_ORIGINALS = "deleteOriginals" - const val KEY_LEFTOVER_URIS = "leftoverUris" - const val OP_ENCRYPT = "encrypt" - const val OP_DECRYPT = "decrypt" - const val OP_HIDE = "hide" - const val OP_MIGRATE = "migrate" - } -} - -// Enqueue helpers -fun WorkManager.enqueueVaultOperation( - operation: String, - media: List, - vault: Vault?, - uniqueKey: String = UUID.randomUUID().toString(), - deleteOriginals: Boolean = false -) { - val pairs = mutableListOf>( - VaultOperationWorker.KEY_OPERATION to operation, - VaultOperationWorker.KEY_MEDIA_URIS to Json.encodeToString(media.map { it.toString() }) - ) - if (vault != null) { - pairs += VaultOperationWorker.KEY_VAULT to Json.encodeToString(vault) - } - if (deleteOriginals) { - pairs += VaultOperationWorker.KEY_DELETE_ORIGINALS to true - } - val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(*pairs.toTypedArray())) - .addTag("VaultOp") - .build() - enqueueUniqueWork("VaultOp_$uniqueKey", ExistingWorkPolicy.KEEP, request) -} - -// Overload returning WorkRequest ID so callers can observe completion before destructive actions (e.g., deletion). -fun WorkManager.enqueueVaultOperationWithId( - operation: String, - media: List, - vault: Vault?, - uniqueKey: String = UUID.randomUUID().toString(), - deleteOriginals: Boolean = false -): UUID { - val pairs = mutableListOf>( - VaultOperationWorker.KEY_OPERATION to operation, - VaultOperationWorker.KEY_MEDIA_URIS to Json.encodeToString(media.map { it.toString() }) - ) - if (vault != null) { - pairs += VaultOperationWorker.KEY_VAULT to Json.encodeToString(vault) - } - if (deleteOriginals) { - pairs += VaultOperationWorker.KEY_DELETE_ORIGINALS to true - } - val request = OneTimeWorkRequestBuilder() - .setInputData(workDataOf(*pairs.toTypedArray())) - .addTag("VaultOp") - .build() - enqueueUniqueWork("VaultOp_$uniqueKey", ExistingWorkPolicy.REPLACE, request) - return request.id -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumGroupDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumGroupDao.kt index f3396c6a03..d72b1f6a4a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumGroupDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumGroupDao.kt @@ -11,8 +11,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.AlbumGroup +import com.dot.gallery.feature_node.data.model.AlbumGroupMember import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao.kt index a1b223ac01..379b0628bb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/AlbumThumbnailDao.kt @@ -3,7 +3,7 @@ package com.dot.gallery.feature_node.data.data_source import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.AlbumThumbnail +import com.dot.gallery.feature_node.data.model.AlbumThumbnail import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt index 2f1e1b78dd..568c8b6bf1 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt @@ -4,7 +4,7 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryClassificationGeneration.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryClassificationGeneration.kt new file mode 100644 index 0000000000..b422ac67e8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryClassificationGeneration.kt @@ -0,0 +1,14 @@ +package com.dot.gallery.feature_node.data.data_source + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "category_classification_generation") +data class CategoryClassificationGeneration( + @PrimaryKey val singletonId: Int = SINGLETON_ID, + val generationId: String, +) { + companion object { + const val SINGLETON_ID = 0 + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryDao.kt index dc517a6a97..7f300dfc8b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CategoryDao.kt @@ -13,8 +13,9 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.MediaCategory +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.MediaCategory import kotlinx.coroutines.flow.Flow @Dao @@ -78,20 +79,17 @@ interface CategoryDao { @Upsert suspend fun insertMediaCategory(mediaCategory: MediaCategory) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMediaCategories(mediaCategories: List) - @Query("DELETE FROM media_category WHERE mediaId = :mediaId AND categoryId = :categoryId") suspend fun removeMediaFromCategory(mediaId: Long, categoryId: Long) - @Query("DELETE FROM media_category WHERE categoryId = :categoryId") - suspend fun removeAllMediaFromCategory(categoryId: Long) - @Query("DELETE FROM media_category WHERE mediaId = :mediaId") suspend fun removeMediaFromAllCategories(mediaId: Long) - @Query("DELETE FROM media_category WHERE mediaId NOT IN (:validMediaIds)") - suspend fun cleanupOrphanedMediaCategories(validMediaIds: List) + @Query("DELETE FROM media_category WHERE mediaId IN (:mediaIds)") + suspend fun removeMediaFromAllCategories(mediaIds: Set) + + @Query("DELETE FROM media_category WHERE mediaId IN (:mediaIds) AND isManuallyAdded = 0") + suspend fun removeGeneratedMediaFromAllCategories(mediaIds: Set) // Get all media IDs in a category, ordered by similarity score @Query(""" @@ -153,6 +151,9 @@ interface CategoryDao { @Query("SELECT similarityScore FROM media_category WHERE mediaId = :mediaId AND categoryId = :categoryId") suspend fun getSimilarityScore(mediaId: Long, categoryId: Long): Float? + @Query("SELECT * FROM media_category WHERE mediaId = :mediaId AND categoryId = :categoryId") + suspend fun getMediaCategory(mediaId: Long, categoryId: Long): MediaCategory? + // Check if a media item is in a category @Query("SELECT EXISTS(SELECT 1 FROM media_category WHERE mediaId = :mediaId AND categoryId = :categoryId)") suspend fun isMediaInCategory(mediaId: Long, categoryId: Long): Boolean @@ -161,6 +162,12 @@ interface CategoryDao { @Query("SELECT DISTINCT mediaId FROM media_category") suspend fun getAllClassifiedMediaIds(): List + @Query( + "SELECT DISTINCT mediaId FROM media_category " + + "WHERE mediaId > :afterId ORDER BY mediaId LIMIT :limit", + ) + suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List + // Get categories with media count - for displaying in the grid @Query(""" SELECT c.*, COUNT(mc.mediaId) as mediaCount, @@ -176,21 +183,129 @@ interface CategoryDao { fun getCategoriesWithMediaCount(): Flow> // Batch update for reclassification + @Query("DELETE FROM media_category WHERE categoryId = :categoryId AND isManuallyAdded = 0") + suspend fun removeGeneratedMediaFromCategory(categoryId: Long) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertGeneratedMediaCategories(mediaCategories: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGeneratedMediaCategoryStaging( + mediaCategories: List, + ) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setCategoryClassificationGeneration( + generation: CategoryClassificationGeneration, + ) + + @Query( + "SELECT generationId FROM category_classification_generation " + + "WHERE singletonId = ${CategoryClassificationGeneration.SINGLETON_ID}", + ) + suspend fun getCategoryClassificationGeneration(): String? + + @Query("DELETE FROM generated_media_category_staging") + suspend fun clearAllGeneratedMediaCategoryStaging() + + @Query("SELECT COUNT(*) FROM generated_media_category_staging") + suspend fun getGeneratedMediaCategoryStagingCount(): Int + + @Query("DELETE FROM generated_media_category_staging WHERE generationId = :generationId") + suspend fun clearGeneratedMediaCategoryStaging(generationId: String) + + @Query( + "DELETE FROM category_classification_generation " + + "WHERE singletonId = ${CategoryClassificationGeneration.SINGLETON_ID} " + + "AND generationId = :generationId", + ) + suspend fun clearCategoryClassificationGeneration(generationId: String) + + @Query("DELETE FROM category_classification_generation") + suspend fun clearAllCategoryClassificationGenerations() + + @Query( + """ + INSERT OR IGNORE INTO media_category ( + mediaId, categoryId, similarityScore, addedAt, isManuallyAdded + ) + SELECT mediaId, categoryId, similarityScore, addedAt, 0 + FROM generated_media_category_staging + WHERE generationId = :generationId + """, + ) + suspend fun publishGeneratedMediaCategoryStaging(generationId: String) + + @Transaction + suspend fun beginGeneratedMediaCategoryStaging(generationId: String) { + setCategoryClassificationGeneration( + generation = CategoryClassificationGeneration(generationId = generationId), + ) + clearAllGeneratedMediaCategoryStaging() + } + @Transaction - suspend fun reclassifyMediaForCategory(categoryId: Long, mediaCategories: List) { - removeAllMediaFromCategory(categoryId) - insertMediaCategories(mediaCategories) + suspend fun stageGeneratedMediaCategories( + generationId: String, + mediaCategories: List, + ): Boolean { + if (getCategoryClassificationGeneration() != generationId) { + return false + } + insertGeneratedMediaCategoryStaging(mediaCategories = mediaCategories) + return true + } + + @Transaction + suspend fun abandonGeneratedMediaCategoryStaging(generationId: String) { + clearGeneratedMediaCategoryStaging(generationId = generationId) + clearCategoryClassificationGeneration(generationId = generationId) + } + + @Transaction + suspend fun clearGeneratedMediaCategoryStagingState() { + clearAllGeneratedMediaCategoryStaging() + clearAllCategoryClassificationGenerations() + } + + @Transaction + suspend fun replaceGeneratedMediaCategoriesFromStaging(generationId: String): Boolean { + if (getCategoryClassificationGeneration() != generationId) { + clearGeneratedMediaCategoryStaging(generationId = generationId) + return false + } + deleteAllGeneratedMediaCategories() + publishGeneratedMediaCategoryStaging(generationId = generationId) + clearGeneratedMediaCategoryStaging(generationId = generationId) + clearCategoryClassificationGeneration(generationId = generationId) + return true + } + + @Transaction + suspend fun reclassifyMediaForCategory( + categoryId: Long, + mediaCategories: List, + ) { + removeGeneratedMediaFromCategory(categoryId = categoryId) + insertGeneratedMediaCategories(mediaCategories = mediaCategories) } // Delete all data (for reset) @Query("DELETE FROM media_category") suspend fun deleteAllMediaCategories() + @Query("DELETE FROM media_category WHERE isManuallyAdded = 0") + suspend fun deleteAllGeneratedMediaCategories() + + @Query("UPDATE categories SET embedding = NULL") + suspend fun clearCategoryEmbeddings() + @Query("DELETE FROM categories") suspend fun deleteAllCategories() @Transaction suspend fun resetAllCategoryData() { + clearGeneratedMediaCategoryStagingState() deleteAllMediaCategories() deleteAllCategories() } @@ -209,75 +324,3 @@ interface CategoryDao { """) fun getTopCategoriesByMediaCount(limit: Int = 10): Flow> } - -/** - * Helper class for queries that return category with media count - */ -data class CategoryWithMediaCount( - val id: Long, - val name: String, - val searchTerms: String, - val embedding: FloatArray?, - val referenceImageIds: List, - val threshold: Float, - val isUserCreated: Boolean, - val isPinned: Boolean, - val createdAt: Long, - val updatedAt: Long, - val mediaCount: Int, - val thumbnailMediaId: Long? -) { - fun toCategory() = Category( - id = id, - name = name, - searchTerms = searchTerms, - embedding = embedding, - referenceImageIds = referenceImageIds, - threshold = threshold, - isUserCreated = isUserCreated, - isPinned = isPinned, - createdAt = createdAt, - updatedAt = updatedAt - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as CategoryWithMediaCount - - if (id != other.id) return false - if (name != other.name) return false - if (searchTerms != other.searchTerms) return false - if (embedding != null) { - if (other.embedding == null) return false - if (!embedding.contentEquals(other.embedding)) return false - } else if (other.embedding != null) return false - if (referenceImageIds != other.referenceImageIds) return false - if (threshold != other.threshold) return false - if (isUserCreated != other.isUserCreated) return false - if (isPinned != other.isPinned) return false - if (createdAt != other.createdAt) return false - if (updatedAt != other.updatedAt) return false - if (mediaCount != other.mediaCount) return false - if (thumbnailMediaId != other.thumbnailMediaId) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + name.hashCode() - result = 31 * result + searchTerms.hashCode() - result = 31 * result + (embedding?.contentHashCode() ?: 0) - result = 31 * result + referenceImageIds.hashCode() - result = 31 * result + threshold.hashCode() - result = 31 * result + isUserCreated.hashCode() - result = 31 * result + isPinned.hashCode() - result = 31 * result + createdAt.hashCode() - result = 31 * result + updatedAt.hashCode() - result = 31 * result + mediaCount - result = 31 * result + (thumbnailMediaId?.hashCode() ?: 0) - return result - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ClassifierDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ClassifierDao.kt index e64332b84b..a5e263876d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ClassifierDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ClassifierDao.kt @@ -5,8 +5,8 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.ClassifiedMedia +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.Media.ClassifiedMedia import kotlinx.coroutines.flow.Flow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CollectionDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CollectionDao.kt index d282d1e7b1..e25df68fdb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CollectionDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/CollectionDao.kt @@ -12,9 +12,10 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update -import com.dot.gallery.feature_node.domain.model.Collection -import com.dot.gallery.feature_node.domain.model.CollectionAlbum -import com.dot.gallery.feature_node.domain.model.CollectionMedia +import com.dot.gallery.feature_node.data.model.Collection +import com.dot.gallery.feature_node.data.model.CollectionAlbum +import com.dot.gallery.feature_node.data.model.CollectionMedia +import com.dot.gallery.feature_node.data.model.CollectionWithThumbnail import kotlinx.coroutines.flow.Flow @Dao @@ -117,54 +118,19 @@ interface CollectionDao { @Query("SELECT albumId FROM collection_albums WHERE collectionId = :collectionId") fun getAlbumIdsInCollection(collectionId: Long): Flow> - // Collections with count for UI display + @Query("SELECT * FROM collection_media") + fun getAllCollectionMedia(): Flow> + + // Collections with their resolved thumbnail for UI display. The count and total size are + // derived from live media in the repository — see [CollectionWithCount]. @Query(""" - SELECT c.*, COUNT(cm.mediaId) as mediaCount, + SELECT c.*, COALESCE( c.coverMediaId, - (SELECT cm2.mediaId FROM collection_media cm2 WHERE cm2.collectionId = c.id ORDER BY cm2.addedAt DESC LIMIT 1) - ) as thumbnailMediaId, - COALESCE( - (SELECT SUM(m.size) FROM collection_media cm3 INNER JOIN media m ON cm3.mediaId = m.id WHERE cm3.collectionId = c.id), - 0 - ) as totalSize + (SELECT cm.mediaId FROM collection_media cm WHERE cm.collectionId = c.id ORDER BY cm.addedAt DESC LIMIT 1) + ) as thumbnailMediaId FROM collections c - LEFT JOIN collection_media cm ON c.id = cm.collectionId - GROUP BY c.id ORDER BY c.isPinned DESC, c.sortOrder ASC, c.updatedAt DESC """) - fun getCollectionsWithCount(): Flow> -} - -/** - * Helper class for queries that return collection with media count - */ -data class CollectionWithMediaCount( - val id: Long, - val label: String, - val coverMediaId: Long?, - val isPinned: Boolean, - val sortOrder: Int, - val createdAt: Long, - val updatedAt: Long, - val mediaCount: Int, - val thumbnailMediaId: Long?, - val totalSize: Long -) { - fun toCollection() = Collection( - id = id, - label = label, - coverMediaId = coverMediaId, - isPinned = isPinned, - sortOrder = sortOrder, - createdAt = createdAt, - updatedAt = updatedAt - ) - - fun toCollectionWithCount() = com.dot.gallery.feature_node.domain.model.CollectionWithCount( - collection = toCollection(), - mediaCount = mediaCount, - thumbnailMediaId = thumbnailMediaId, - totalSize = totalSize - ) + fun getCollectionsWithCount(): Flow> } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/EditHistoryDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/EditHistoryDao.kt deleted file mode 100644 index 8be5b517cb..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/EditHistoryDao.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_source - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.EditedMedia -import kotlinx.coroutines.flow.Flow - -@Dao -interface EditHistoryDao { - - @Upsert - suspend fun upsertEditedMedia(editedMedia: EditedMedia) - - @Query("SELECT * FROM edited_media WHERE mediaId = :mediaId LIMIT 1") - suspend fun getEditedMedia(mediaId: Long): EditedMedia? - - @Query("SELECT * FROM edited_media WHERE mediaId = :mediaId LIMIT 1") - fun getEditedMediaFlow(mediaId: Long): Flow - - @Query("SELECT EXISTS(SELECT 1 FROM edited_media WHERE mediaId = :mediaId)") - suspend fun hasOriginalBackup(mediaId: Long): Boolean - - @Query("SELECT EXISTS(SELECT 1 FROM edited_media WHERE mediaId = :mediaId)") - fun hasOriginalBackupFlow(mediaId: Long): Flow - - @Query("DELETE FROM edited_media WHERE mediaId = :mediaId") - suspend fun deleteEditedMedia(mediaId: Long) - - @Query("SELECT * FROM edited_media") - suspend fun getAllEditedMedia(): List - - @Query("DELETE FROM edited_media WHERE mediaId NOT IN (:existingMediaIds)") - suspend fun deleteOrphans(existingMediaIds: List) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/GeneratedMediaCategoryStaging.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/GeneratedMediaCategoryStaging.kt new file mode 100644 index 0000000000..440c893495 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/GeneratedMediaCategoryStaging.kt @@ -0,0 +1,15 @@ +package com.dot.gallery.feature_node.data.data_source + +import androidx.room.Entity + +@Entity( + tableName = "generated_media_category_staging", + primaryKeys = ["generationId", "mediaId", "categoryId"], +) +data class GeneratedMediaCategoryStaging( + val generationId: String, + val mediaId: Long, + val categoryId: Long, + val similarityScore: Float, + val addedAt: Long, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao.kt index f59f941464..6073ce7f07 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ImageEmbeddingDao.kt @@ -3,7 +3,8 @@ package com.dot.gallery.feature_node.data.data_source import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.ImageEmbeddingStamp import kotlinx.coroutines.flow.Flow @Dao @@ -11,9 +12,30 @@ interface ImageEmbeddingDao { @Upsert suspend fun addImageEmbedding(imageEmbedding: ImageEmbedding) + @Upsert + suspend fun addImageEmbeddings(imageEmbeddings: List) + @Query("SELECT * FROM image_embeddings WHERE id = :id LIMIT 1") suspend fun getRecord(id: Long): ImageEmbedding? @Query("SELECT * FROM image_embeddings") fun getRecords(): Flow> -} \ No newline at end of file + + @Query("SELECT id, date FROM image_embeddings WHERE id > :afterId ORDER BY id LIMIT :limit") + suspend fun getStampPage(afterId: Long, limit: Int): List + + @Query("SELECT * FROM image_embeddings WHERE id > :afterId ORDER BY id LIMIT :limit") + suspend fun getPage(afterId: Long, limit: Int): List + + @Query("SELECT * FROM image_embeddings WHERE id IN (:ids)") + suspend fun getByIds(ids: Set): List + + @Query("SELECT COUNT(*) FROM image_embeddings") + suspend fun getCount(): Int + + @Query("DELETE FROM image_embeddings") + suspend fun deleteAll() + + @Query("DELETE FROM image_embeddings WHERE id IN (:ids)") + suspend fun deleteByIds(ids: Set) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt index ab680c0361..759a79d8ee 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt @@ -5,34 +5,31 @@ package com.dot.gallery.feature_node.data.data_source -import androidx.room.AutoMigration import androidx.room.Database -import androidx.room.DeleteColumn import androidx.room.RoomDatabase import androidx.room.TypeConverters -import androidx.room.migration.AutoMigrationSpec -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember -import com.dot.gallery.feature_node.domain.model.AlbumThumbnail -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.Collection -import com.dot.gallery.feature_node.domain.model.CollectionAlbum -import com.dot.gallery.feature_node.domain.model.CollectionMedia -import com.dot.gallery.feature_node.domain.model.EditedMedia -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaCategory -import com.dot.gallery.feature_node.domain.model.MediaMetadataCore -import com.dot.gallery.feature_node.domain.model.MediaMetadataFlags -import com.dot.gallery.feature_node.domain.model.MediaMetadataVideo -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.MediaVersion -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.Converters +import com.dot.gallery.feature_node.data.model.AlbumGroup +import com.dot.gallery.feature_node.data.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.AlbumThumbnail +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Collection +import com.dot.gallery.feature_node.data.model.CollectionAlbum +import com.dot.gallery.feature_node.data.model.CollectionMedia +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaCategory +import com.dot.gallery.feature_node.data.model.MediaMetadataCore +import com.dot.gallery.feature_node.data.model.MediaMetadataFlags +import com.dot.gallery.feature_node.data.model.MediaMetadataVideo +import com.dot.gallery.feature_node.data.model.MediaVersion +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.ScannedMedia +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.util.Converters +import com.dot.gallery.feature_node.data.util.EmbeddingBlobConverter @Database( entities = [ @@ -42,8 +39,6 @@ import com.dot.gallery.feature_node.domain.util.Converters MediaVersion::class, TimelineSettings::class, Media.ClassifiedMedia::class, - Media.EncryptedMedia2::class, - Vault::class, MediaMetadataCore::class, MediaMetadataVideo::class, MediaMetadataFlags::class, @@ -51,47 +46,23 @@ import com.dot.gallery.feature_node.domain.util.Converters ImageEmbedding::class, Category::class, MediaCategory::class, - EditedMedia::class, LockedAlbum::class, AlbumGroup::class, AlbumGroupMember::class, MergedSubfolderAlbum::class, Collection::class, CollectionMedia::class, - CollectionAlbum::class + CollectionAlbum::class, + ScannedMedia::class, + CategoryClassificationGeneration::class, + GeneratedMediaCategoryStaging::class, ], - version = 22, + version = 1, exportSchema = true, - autoMigrations = [ - AutoMigration(from = 1, to = 2), - AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4), - AutoMigration(from = 4, to = 5), - AutoMigration(from = 5, to = 6), - AutoMigration(from = 6, to = 7), - AutoMigration(from = 7, to = 8), - AutoMigration(from = 8, to = 9), - AutoMigration(from = 9, to = 10), - AutoMigration(from = 10, to = 11), - AutoMigration(from = 11, to = 12), - // Migration 12 to 13 is handled manually in IgnoredAlbumMigration.kt - AutoMigration(from = 13, to = 14), - AutoMigration(from = 14, to = 15, spec = InternalDatabase.RemoveIconEmojiMigration::class), - AutoMigration(from = 15, to = 16), - AutoMigration(from = 16, to = 17), - AutoMigration(from = 17, to = 18), - AutoMigration(from = 18, to = 19), - AutoMigration(from = 19, to = 20), - AutoMigration(from = 20, to = 21), - AutoMigration(from = 21, to = 22), - ] ) -@TypeConverters(Converters::class) +@TypeConverters(Converters::class, EmbeddingBlobConverter::class) abstract class InternalDatabase : RoomDatabase() { - @DeleteColumn(tableName = "categories", columnName = "iconEmoji") - class RemoveIconEmojiMigration : AutoMigrationSpec - abstract fun getPinnedDao(): PinnedDao abstract fun getBlacklistDao(): BlacklistDao @@ -100,8 +71,6 @@ abstract class InternalDatabase : RoomDatabase() { abstract fun getClassifierDao(): ClassifierDao - abstract fun getVaultDao(): VaultDao - abstract fun getMetadataDao(): MetadataDao abstract fun getAlbumThumbnailDao(): AlbumThumbnailDao @@ -110,8 +79,6 @@ abstract class InternalDatabase : RoomDatabase() { abstract fun getCategoryDao(): CategoryDao - abstract fun getEditHistoryDao(): EditHistoryDao - abstract fun getLockedAlbumDao(): LockedAlbumDao abstract fun getAlbumGroupDao(): AlbumGroupDao @@ -120,7 +87,9 @@ abstract class InternalDatabase : RoomDatabase() { abstract fun getCollectionDao(): CollectionDao + abstract fun getScannedMediaDao(): ScannedMediaDao + companion object { const val NAME = "internal_db" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt deleted file mode 100644 index 5f04184dab..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt +++ /dev/null @@ -1,536 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.dot.gallery.feature_node.data.data_source - -import android.content.Context -import android.net.Uri -import android.security.keystore.UserNotAuthenticatedException -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB -import androidx.security.crypto.MasterKey -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultInfo -import com.dot.gallery.feature_node.domain.util.fromKotlinByteArray -import com.dot.gallery.feature_node.domain.util.toKotlinByteArray -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.ByteArrayOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.File -import java.io.FileOutputStream -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.security.GeneralSecurityException -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.Base64 -import java.util.UUID -import javax.crypto.Cipher -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject - -class KeychainHolder @Inject constructor( - @param:ApplicationContext private val context: Context -) { - - val filesDir: File = context.filesDir - - fun vaultFolder(vault: Vault) = File(filesDir, vault.uuid.toString()) - private fun vaultInfoFile(vault: Vault) = File(vaultFolder(vault), VAULT_INFO_FILE_NAME) - fun Vault.mediaFile(mediaId: Long) = File(vaultFolder(this), "$mediaId.enc") - - private val masterKey: MasterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - /** - * Legacy write: always device-locked (non-transferable). Kept for backward compatibility. - */ - fun writeVaultInfo(vault: Vault, onSuccess: () -> Unit = {}, onFailed: (reason: String) -> Unit = {}) { - try { - val vaultFolder = File(filesDir, vault.uuid.toString()) - if (!vaultFolder.exists()) { - vaultFolder.mkdirs() - } - - vaultInfoFile(vault).apply { - if (exists()) delete() - encryptKotlin(vault) - onSuccess() - } - } catch (e: Exception) { - e.printStackTrace() - onFailed(e.message.toString()) - } - } - - /** - * New portable vault initialization. - * When transferable=true we generate a random 32-byte Data Key (DK), wrap it with EncryptedFile - * and store plaintext metadata (VaultInfo) containing dkHash for integrity. - */ - fun writeVaultInfo( - vault: Vault, - transferable: Boolean, - force: Boolean = false, - onSuccess: () -> Unit = {}, - onFailed: (reason: String) -> Unit = {} - ) { - try { - val folder = vaultFolder(vault) - if (!folder.exists()) folder.mkdirs() - - if (transferable) { - val dkFile = dataKeyFile(vault) - if (!dkFile.exists() || force) { - val dk = generateDataKey() - // Store encrypted with MasterKey for local-at-rest protection - EncryptedFile.Builder( - context, - dkFile, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileOutput().use { it.write(dk) } - val meta = VaultInfo( - version = 1, - transferable = true, - dkHash = dk.sha256Base64(), - uuid = vault.uuid.toString() - ) - vaultInfoFile(vault).writeText(meta.toKotlinByteArray().decodeToString()) - } - } else { - // Non-transferable path: keep previous encrypted info object - writeVaultInfo(vault) - } - onSuccess() - } catch (e: Exception) { - e.printStackTrace() - onFailed(e.message.toString()) - } - } - - fun deleteVault(vault: Vault, onSuccess: () -> Unit, onFailed: (reason: String) -> Unit) { - try { - val vaultFolder = vaultFolder(vault) - if (vaultFolder.exists()) { - vaultFolder.deleteRecursively() - } - onSuccess() - } catch (e: Exception) { - e.printStackTrace() - onFailed(e.message.toString()) - } - } - - fun checkVaultFolder(vault: Vault) { - val mainFolder = File(filesDir, vault.uuid.toString()) - if (!mainFolder.exists()) { - mainFolder.mkdirs() - writeVaultInfo(vault) - } - } - - /** Determine if vault is portable by inspecting metadata file (plaintext JSON). */ - fun isTransferable(vault: Vault): Boolean = try { - val info = readVaultInfo(vault) ?: return false - info.transferable - } catch (_: Exception) { false } - - fun readVaultInfo(vault: Vault): VaultInfo? { - val infoFile = vaultInfoFile(vault) - if (!infoFile.exists()) return null - return try { - // Try plaintext JSON first (portable). If that fails fall back to legacy encrypted - val text = infoFile.readText() - fromKotlinByteArray(text.toByteArray()) - } catch (_: Exception) { - // Legacy path: encrypted Vault object (ignore for portable metadata) - null - } - } - - private fun dataKeyFile(vault: Vault) = File(vaultFolder(vault), DATA_KEY_FILE_NAME) - - private fun generateDataKey(): ByteArray = ByteArray(32).apply { SecureRandom().nextBytes(this) } - - private fun ByteArray.sha256Base64(): String = - Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(this)) - - /** Load raw DK (only for transferable vault). */ - fun loadDataKey(vault: Vault): ByteArray? { - if (!isTransferable(vault)) return null - val file = dataKeyFile(vault) - if (!file.exists()) return null - return EncryptedFile.Builder( - context, - file, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileInput().use { it.readBytes() } - } - - /** Export base64 DK for user backup (only if transferable). */ - fun exportDataKeyBase64(vault: Vault): String? = loadDataKey(vault)?.let { - Base64.getEncoder().encodeToString(it) - } - - // ---- Portable content encryption helpers ---- - // V1 layout: MAGIC_V1(6) | NONCE(12) | CIPHERTEXT+TAG (single GCM, in-memory) - // V2 layout: MAGIC_V2(6) | BASE_NONCE(12) | CHUNK_SIZE(4) | [LEN(4)+CHUNK_CIPHERTEXT]* - private val MAGIC = "VLTv1".toByteArray() // 5 bytes + sentinel - private val MAGIC_V2 = "VLTv2".toByteArray() - private val MAGIC_PAD = 6 // ensure fixed length (add 1 padding byte) - private val STREAM_CHUNK_SIZE = 1 * 1024 * 1024 // 1 MiB per GCM chunk - - fun encryptPortableContent(vault: Vault, plaintext: ByteArray): ByteArray { - val dk = loadDataKey(vault) ?: error("Data key missing for portable vault") - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val nonce = ByteArray(12).apply { SecureRandom().nextBytes(this) } - cipher.init(Cipher.ENCRYPT_MODE, dk.toAesKey(), GCMParameterSpec(128, nonce)) - val ct = cipher.doFinal(plaintext) - return ByteArray(MAGIC_PAD + nonce.size + ct.size).apply { - System.arraycopy(MAGIC, 0, this, 0, MAGIC.size) - this[MAGIC.size] = 0 // padding byte - System.arraycopy(nonce, 0, this, MAGIC_PAD, nonce.size) - System.arraycopy(ct, 0, this, MAGIC_PAD + nonce.size, ct.size) - } - } - - /** - * Chunked streaming encryption (VLTv2). Each chunk is independently GCM-encrypted - * so the cipher never accumulates more than STREAM_CHUNK_SIZE in memory. - */ - fun encryptPortableStream(vault: Vault, input: InputStream, outputFile: File) { - val dk = loadDataKey(vault) ?: error("Data key missing for portable vault") - val key = dk.toAesKey() - val baseNonce = ByteArray(12).apply { SecureRandom().nextBytes(this) } - - DataOutputStream(outputFile.outputStream().buffered()).use { dos -> - // Header - dos.write(MAGIC_V2) - dos.write(ByteArray(MAGIC_PAD - MAGIC_V2.size)) // padding to 6 bytes - dos.write(baseNonce) - dos.writeInt(STREAM_CHUNK_SIZE) - - val readBuf = ByteArray(STREAM_CHUNK_SIZE) - var chunkIndex = 0L - while (true) { - val bytesRead = readFully(input, readBuf) - if (bytesRead == 0) break - - val chunkNonce = deriveChunkNonce(baseNonce, chunkIndex++) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, chunkNonce)) - val ct = cipher.doFinal(readBuf, 0, bytesRead) - dos.writeInt(ct.size) - dos.write(ct) - } - } - } - - fun decryptPortableContent(vault: Vault, bytes: ByteArray): ByteArray { - if (!startsWithMagic(bytes)) error("Not portable content") - val dk = loadDataKey(vault) ?: error("Data key missing") - val nonce = bytes.copyOfRange(MAGIC_PAD, MAGIC_PAD + 12) - val ct = bytes.copyOfRange(MAGIC_PAD + 12, bytes.size) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, dk.toAesKey(), GCMParameterSpec(128, nonce)) - return cipher.doFinal(ct) - } - - /** - * Streaming decryption that auto-detects VLTv1 (single GCM) vs VLTv2 (chunked GCM). - */ - fun decryptPortableStream(vault: Vault, inputFile: File, output: OutputStream) { - val dk = loadDataKey(vault) ?: error("Data key missing") - val key = dk.toAesKey() - inputFile.inputStream().buffered().use { input -> - val dis = DataInputStream(input) - val header = ByteArray(MAGIC_PAD) - dis.readFully(header) - - if (startsWithMagicV2(header)) { - // VLTv2 chunked decryption - val baseNonce = ByteArray(12) - dis.readFully(baseNonce) - val chunkSize = dis.readInt() // stored but not strictly needed for read - - var chunkIndex = 0L - while (dis.available() > 0 || input.available() > 0) { - val ctLen = try { dis.readInt() } catch (_: EOFException) { break } - val ct = ByteArray(ctLen) - dis.readFully(ct) - val chunkNonce = deriveChunkNonce(baseNonce, chunkIndex++) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, chunkNonce)) - val plain = cipher.doFinal(ct) - output.write(plain) - } - } else if (startsWithMagic(header)) { - // VLTv1 single-GCM (legacy streaming — works for small files) - val nonce = ByteArray(12) - dis.readFully(nonce) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, nonce)) - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var len: Int - while (input.read(buffer).also { len = it } != -1) { - val decrypted = cipher.update(buffer, 0, len) - if (decrypted != null) output.write(decrypted) - } - val finalBlock = cipher.doFinal() - if (finalBlock != null && finalBlock.isNotEmpty()) output.write(finalBlock) - } else { - error("Not portable content") - } - } - } - - fun isPortableFile(file: File): Boolean { - if (!file.exists() || file.length() < MAGIC_PAD) return false - val header = ByteArray(MAGIC_PAD) - file.inputStream().use { it.read(header) } - return startsWithMagic(header) || startsWithMagicV2(header) - } - - /** - * Attempt portable decryption; if magic header absent falls back to returning original bytes. - */ - fun decryptPortableIfNeeded(vault: Vault, bytes: ByteArray): ByteArray = - if (startsWithMagic(bytes)) decryptPortableContent(vault, bytes) else bytes - - /** - * Import a portable vault by injecting a provided Base64 data key. - * If vault folder exists and already has key, it's left unchanged unless force=true. - */ - fun importPortableVault(vault: Vault, base64Key: String, force: Boolean = false): Boolean { - return try { - val dk = Base64.getDecoder().decode(base64Key) - require(dk.size == 32) { "Invalid key length" } - val folder = vaultFolder(vault) - if (!folder.exists()) folder.mkdirs() - val dkFile = dataKeyFile(vault) - if (dkFile.exists() && !force) return true - // Write encrypted DK file - EncryptedFile.Builder( - context, - dkFile, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileOutput().use { it.write(dk) } - val meta = VaultInfo( - version = 1, - transferable = true, - dkHash = dk.sha256Base64(), - uuid = vault.uuid.toString() - ) - vaultInfoFile(vault).writeText(meta.toKotlinByteArray().decodeToString()) - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - - /** - * Migrate an existing non-transferable (legacy) vault to portable format. - * Creates data key and re-encrypts each legacy EncryptedMedia file into portable binary format. - */ - fun migrateVaultToPortable(vault: Vault, onProgress: (Int, Int) -> Unit = { _, _ -> }): Boolean { - return try { - if (isTransferable(vault)) return true // already portable - // Initialize transferable vault (generates DK & metadata) - writeVaultInfo(vault, transferable = true) - val files = vaultFolder(vault).listFiles { f -> f.name.endsWith(".enc") } ?: emptyArray() - var index = 0 - files.forEach { f -> - try { - val encrypted = f.decryptKotlin() - val portableBytes = encryptPortableContent(vault, encrypted.bytes) - f.writeBytes(portableBytes) - } catch (e: Throwable) { - // leave file as-is if failure; could log - e.printStackTrace() - } - index++ - onProgress(index, files.size) - } - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - - internal fun startsWithMagic(bytes: ByteArray): Boolean { - if (bytes.size < MAGIC_PAD) return false - for (i in MAGIC.indices) if (bytes[i] != MAGIC[i]) return false - return true - } - - internal fun startsWithMagicV2(bytes: ByteArray): Boolean { - if (bytes.size < MAGIC_PAD) return false - for (i in MAGIC_V2.indices) if (bytes[i] != MAGIC_V2[i]) return false - return true - } - - fun isPortableV2File(file: File): Boolean { - if (!file.exists() || file.length() < MAGIC_PAD) return false - val header = ByteArray(MAGIC_PAD) - file.inputStream().use { it.read(header) } - return startsWithMagicV2(header) - } - - /** XOR chunk index into the last 8 bytes of the base nonce to derive a unique per-chunk nonce. */ - private fun deriveChunkNonce(baseNonce: ByteArray, chunkIndex: Long): ByteArray { - val nonce = baseNonce.copyOf() - for (i in 0 until 8) { - nonce[nonce.size - 1 - i] = (nonce[nonce.size - 1 - i].toInt() xor ((chunkIndex shr (i * 8)) and 0xFF).toInt()).toByte() - } - return nonce - } - - /** Read up to buf.size bytes from input, returning actual count (0 at EOF). */ - private fun readFully(input: InputStream, buf: ByteArray): Int { - var offset = 0 - while (offset < buf.size) { - val n = input.read(buf, offset, buf.size - offset) - if (n == -1) break - offset += n - } - return offset - } - - private fun ByteArray.toAesKey(): SecretKey = SecretKeySpec(this, "AES") - - @Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class) - internal inline fun File.decryptKotlin(): T = EncryptedFile.Builder( - context, - this, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileInput().use { - fromKotlinByteArray(it.readBytes()) - } -/* - @Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class) - fun File.decrypt(): T = EncryptedFile.Builder( - context, - this, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileInput().use { - fromByteArray(it.readBytes()) - }*/ - - @Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class) - internal inline fun File.encryptKotlin(data: T) { - EncryptedFile.Builder( - context, - this, - masterKey, - AES256_GCM_HKDF_4KB - ).build().openFileOutput().use { - it.write(data.toKotlinByteArray()) - } - } - - @Throws(IOException::class) - fun getBytes(uri: Uri): ByteArray? = - context.contentResolver.openInputStream(uri)?.use { inputStream -> - val byteBuffer = ByteArrayOutputStream() - val bufferSize = 1024 - val buffer = ByteArray(bufferSize) - - var len: Int - while (inputStream.read(buffer).also { len = it } != -1) { - byteBuffer.write(buffer, 0, len) - } - byteBuffer.toByteArray() - } - - /** - * Unified vault file decryption that handles both portable (VLTv1) and legacy (EncryptedFile) formats. - * Returns raw decrypted bytes and the mime type. - */ - /** - * Decrypt a VLTv2 file to a temporary file (streaming, constant memory). - * Returns the temp file and sniffed MIME type. - */ - fun decryptToTempFile(file: File, cacheDir: File? = null): DecryptedVaultMedia { - val vaultUuidStr = file.parentFile?.name ?: error("Cannot determine vault UUID from path") - val vault = Vault(uuid = UUID.fromString(vaultUuidStr), name = "") - val tmpDir = cacheDir ?: filesDir - val tempFile = File.createTempFile("vault_dec_", ".tmp", tmpDir) - FileOutputStream(tempFile).use { out -> - decryptPortableStream(vault, file, out) - } - // Sniff MIME from first 12 bytes of decrypted content - val header = ByteArray(12) - tempFile.inputStream().use { it.read(header) } - val mime = sniffMimeType(header) - return DecryptedVaultMedia(bytes = null, mimeType = mime, tempFile = tempFile) - } - - fun decryptVaultMedia(file: File): DecryptedVaultMedia { - val vaultUuidStr = file.parentFile?.name ?: error("Cannot determine vault UUID from path") - val vault = Vault(uuid = UUID.fromString(vaultUuidStr), name = "") - - if (isPortableV2File(file)) { - // VLTv2: stream-decrypt to temp file (constant memory) - return decryptToTempFile(file) - } - if (isPortableFile(file)) { - val encBytes = file.readBytes() - val plainBytes = decryptPortableContent(vault, encBytes) - val mime = sniffMimeType(plainBytes) - return DecryptedVaultMedia(plainBytes, mime) - } - val enc = file.decryptKotlin() - return DecryptedVaultMedia(enc.bytes, enc.mimeType) - } - - companion object { - const val VAULT_INFO_FILE_NAME = "info.vault" - const val DATA_KEY_FILE_NAME = "vault.key.enc" - - fun sniffMimeType(bytes: ByteArray): String { - if (bytes.size < 12) return "application/octet-stream" - if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte()) return "image/jpeg" - if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) return "image/png" - if (bytes[0] == 0x47.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte()) return "image/gif" - if (bytes[0] == 0x52.toByte() && bytes[1] == 0x49.toByte() && bytes[2] == 0x46.toByte() && bytes[3] == 0x46.toByte() && - bytes[8] == 0x57.toByte() && bytes[9] == 0x45.toByte() && bytes[10] == 0x42.toByte() && bytes[11] == 0x50.toByte() - ) return "image/webp" - if (bytes[4] == 0x66.toByte() && bytes[5] == 0x74.toByte() && bytes[6] == 0x79.toByte() && bytes[7] == 0x70.toByte()) { - val brand = String(bytes, 8, 4) - if (brand.startsWith("heic") || brand.startsWith("heix") || brand.startsWith("mif1")) return "image/heif" - return "video/mp4" - } - if (bytes[0] == 0x42.toByte() && bytes[1] == 0x4D.toByte()) return "image/bmp" - return "application/octet-stream" - } - } -} - -data class DecryptedVaultMedia( - val bytes: ByteArray?, - val mimeType: String, - val tempFile: File? = null -) { - /** Read bytes from either in-memory array or temp file. */ - fun readBytes(): ByteArray = bytes ?: tempFile?.readBytes() ?: error("No data available") - - /** Open an InputStream over the decrypted content without loading everything into memory. */ - fun openStream(): InputStream = bytes?.inputStream() ?: tempFile?.inputStream() ?: error("No data available") - - /** Clean up temp file if one was created. */ - fun cleanup() { tempFile?.delete() } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/LockedAlbumDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/LockedAlbumDao.kt index 43c90bf733..200fb11f96 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/LockedAlbumDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/LockedAlbumDao.kt @@ -9,7 +9,7 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt index e9fca37b9b..6eb30a967c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt @@ -4,9 +4,9 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.Media.UriMedia -import com.dot.gallery.feature_node.domain.model.MediaVersion -import com.dot.gallery.feature_node.domain.model.TimelineSettings +import com.dot.gallery.feature_node.data.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.MediaVersion +import com.dot.gallery.feature_node.data.model.TimelineSettings import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MergedSubfolderDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MergedSubfolderDao.kt index 55aa83a2a6..04cc6b3ab8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MergedSubfolderDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MergedSubfolderDao.kt @@ -9,7 +9,7 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MetadataDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MetadataDao.kt index d5e26584d9..ef77be426a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MetadataDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MetadataDao.kt @@ -4,15 +4,15 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.FullMediaMetadata -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.feature_node.domain.model.MediaMetadataCore -import com.dot.gallery.feature_node.domain.model.MediaMetadataFlags -import com.dot.gallery.feature_node.domain.model.MediaMetadataVideo -import com.dot.gallery.feature_node.domain.model.MediaVersion -import com.dot.gallery.feature_node.domain.model.toCore -import com.dot.gallery.feature_node.domain.model.toFlags -import com.dot.gallery.feature_node.domain.model.toVideo +import com.dot.gallery.feature_node.data.model.FullMediaMetadata +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.model.MediaMetadataCore +import com.dot.gallery.feature_node.data.model.MediaMetadataFlags +import com.dot.gallery.feature_node.data.model.MediaMetadataVideo +import com.dot.gallery.feature_node.data.model.MediaVersion +import com.dot.gallery.feature_node.data.model.toCore +import com.dot.gallery.feature_node.data.model.toFlags +import com.dot.gallery.feature_node.data.model.toVideo import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/PinnedDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/PinnedDao.kt index b0045b5bab..6bf07c8350 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/PinnedDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/PinnedDao.kt @@ -9,7 +9,7 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunking.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunking.kt new file mode 100644 index 0000000000..2f5e02c146 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunking.kt @@ -0,0 +1,27 @@ +package com.dot.gallery.feature_node.data.data_source + +/** + * Upper bound on the ids bound into a single `IN (:ids)` query. + * + * SQLite caps the number of bound variables per statement at 999 on the versions shipped with + * older API levels, so id collections have to be split before they reach a DAO. The value is + * deliberately below the cap to leave room for the other parameters a query may bind. + */ +private const val MAX_QUERY_IDS = 900 + +/** + * Runs [action] once per chunk of [ids], each chunk small enough to bind into a single query. + */ +internal suspend fun forEachIdChunk(ids: Collection, action: suspend (Set) -> Unit) { + ids.chunked(size = MAX_QUERY_IDS).forEach { chunk -> action(chunk.toSet()) } +} + +/** + * Queries [ids] one chunk at a time via [query] and concatenates the rows each chunk returns. + */ +internal suspend fun flatMapIdChunks( + ids: Collection, + query: suspend (Set) -> List, +): List { + return ids.chunked(size = MAX_QUERY_IDS).flatMap { chunk -> query(chunk.toSet()) } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ScannedMediaDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ScannedMediaDao.kt new file mode 100644 index 0000000000..2cd308246c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/ScannedMediaDao.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.data_source + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.dot.gallery.feature_node.data.model.ScannedMedia + +@Dao +interface ScannedMediaDao { + + @Query("SELECT id FROM scanned_media") + suspend fun getScannedIds(): List + + @Upsert + suspend fun insertAll(items: List) + + @Query("DELETE FROM scanned_media WHERE id NOT IN (SELECT id FROM media)") + suspend fun removeStaleEntries() +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/VaultDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/VaultDao.kt deleted file mode 100644 index ec2d79d99f..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/VaultDao.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.dot.gallery.feature_node.data.data_source - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import kotlinx.coroutines.flow.Flow -import java.util.UUID - -data class VaultMediaCount( - val uuid: UUID, - val count: Int -) - -@Dao -interface VaultDao { - - /** - * Vault Management - */ - - @Query("SELECT * FROM vaults") - fun getVaults(): Flow> - - @Upsert - suspend fun insertVault(vault: Vault) - - @Query("SELECT * FROM vaults WHERE uuid = :uuid LIMIT 1") - suspend fun getVault(uuid: UUID): Vault? - - @Delete - suspend fun deleteVaultInfo(vault: Vault) - - @Transaction - suspend fun deleteVault(vault: Vault) { - deleteAllMediaFromVault(vault.uuid) - deleteVaultInfo(vault) - } - - @Query("DELETE FROM vaults") - suspend fun deleteAllVaults() - - /** - * Vault Media Management - */ - - @Query("SELECT * FROM encrypted_media") - fun getAllMedia(): Flow> - - @Query("SELECT * FROM encrypted_media WHERE uuid = :uuid") - fun getMediaFromVault(uuid: UUID?): Flow> - - @Query("SELECT uuid, COUNT(*) as count FROM encrypted_media GROUP BY uuid") - fun getMediaCountPerVault(): Flow> - - @Query("SELECT EXISTS(SELECT 1 FROM encrypted_media WHERE uuid = :uuid AND id = :id)") - suspend fun mediaExistsInVault(uuid: UUID, id: Long): Boolean - - @Upsert - suspend fun addMediaToVault(media: Media.EncryptedMedia2) - - @Query("DELETE FROM encrypted_media WHERE uuid = :uuid AND id = :id") - suspend fun deleteMediaFromVault(uuid: UUID, id: Long): Int - - @Transaction - suspend fun deleteMediaFromVault(media: Media.EncryptedMedia2): Boolean { - return deleteMediaFromVault(uuid = media.uuid, id = media.id) > 0 - } - - @Query("DELETE FROM encrypted_media WHERE uuid = :uuid") - suspend fun deleteAllMediaFromVault(uuid: UUID) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/AlbumsFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/AlbumsFlow.kt index 0893f1008d..8f2f3da7f9 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/AlbumsFlow.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/AlbumsFlow.kt @@ -18,8 +18,8 @@ import com.dot.gallery.core.util.ext.queryFlow import com.dot.gallery.core.util.ext.tryGetString import com.dot.gallery.core.util.join import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.MediaType +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.MediaType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlow.kt index dae13555c1..62c4c077f5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlow.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaFlow.kt @@ -25,9 +25,10 @@ import com.dot.gallery.core.util.ext.tryGetLong import com.dot.gallery.core.util.ext.tryGetString import com.dot.gallery.core.util.join import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaType +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaType import com.dot.gallery.feature_node.presentation.util.getDate +import com.dot.gallery.feature_node.presentation.util.parseTimestampFromFilename import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -44,7 +45,9 @@ class MediaFlow( private val contentResolver: ContentResolver, private val buckedId: Long, private val mimeType: String? = null, - private val skipBatching: Boolean = false + private val skipBatching: Boolean = false, + private val afterId: Long? = null, + private val limit: Int? = null, ) : QueryFlow() { init { assert(buckedId != MediaStoreBuckets.MEDIA_STORE_BUCKET_PLACEHOLDER.id) { @@ -101,31 +104,47 @@ class MediaFlow( } // Join all the non-null queries - val selection = listOfNotNull( + val mediaSelection = listOfNotNull( imageOrVideo, albumFilter, mimeTypeQuery, ).join(Query::and) - val selectionArgs = listOfNotNull( + val idSelection = afterId?.let { + "${MediaStore.Files.FileColumns._ID} > ${Query.ARG}" + } + val selection = listOfNotNull( + mediaSelection?.build(), + idSelection, + ) + .takeIf { clauses -> clauses.isNotEmpty() } + ?.joinToString(separator = " AND ") { clause -> "($clause)" } + + val selectionArgs = buildList { buckedId.takeIf { MediaStoreBuckets.entries.toTypedArray().none { bucket -> it == bucket.id } - }?.toString(), - rawMimeType, - ).toTypedArray() - - val sortOrder = when (buckedId) { - MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> + } + ?.toString() + ?.let(::add) + rawMimeType?.let(::add) + afterId?.toString()?.let(::add) + }.toTypedArray() + + val sortOrder = when { + afterId != null -> "${MediaStore.Files.FileColumns._ID} ASC" + buckedId == MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> if (SdkCompat.supportsTrash) "${MediaStore.Files.FileColumns.DATE_EXPIRES} DESC" else "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" - else -> "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" } val queryArgs = Bundle().apply { - putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection?.build()) + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs) putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder) + limit?.let { pageLimit -> + putInt(ContentResolver.QUERY_ARG_LIMIT, pageLimit) + } // Exclude trashed media unless we want data for the trashed album // QUERY_ARG_MATCH_TRASHED is only available on API 30+ @@ -140,65 +159,81 @@ class MediaFlow( } } return if (skipBatching) { - contentResolver.queryFlow( - uri, - projection, - queryArgs, + contentResolver.queryFlow( + uri = uri, + projection = projection, + queryArgs = queryArgs, ) - } else contentResolver.querySteppedFlow( - uri, - projection, - queryArgs, - ) + } else { + contentResolver.querySteppedFlow( + uri = uri, + projection = projection, + queryArgs = queryArgs, + ) + } } - override fun flowData() = flowCursor().mapEachRow( - when (buckedId) { - MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaQuery.MediaProjectionTrash - else -> MediaQuery.MediaProjection + override fun flowData(): Flow> { + return flowCursor().mapEachRow( + projection = when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaQuery.MediaProjectionTrash + else -> MediaQuery.MediaProjection + }, + ) { it, indexCache -> + var i = 0 + + val id = it.getLong(indexCache[i++]) + val path = it.getString(indexCache[i++]) + val relativePath = it.getString(indexCache[i++]) + val title = it.getString(indexCache[i++]) + val albumID = it.getLong(indexCache[i++]) + val albumLabel = it.tryGetString(indexCache[i++], Build.MODEL) + val takenTimestamp = it.tryGetLong(indexCache[i++]) + ?: title.parseTimestampFromFilename() + val modifiedTimestamp = it.getLong(indexCache[i++]) + val duration = it.tryGetString(indexCache[i++]) + val size = it.getLong(indexCache[i++]) + val mimeType = it.getString(indexCache[i++]) + // IS_FAVORITE and IS_TRASHED are only available on API 30+ + val isFavorite = if (SdkCompat.supportsFavorites) it.getInt(indexCache[i++]) else 0 + val isTrashAlbum = buckedId == MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id + val isTrashed = if (SdkCompat.supportsTrash) { + it.getInt(indexCache[if (isTrashAlbum) i++ else i]) + } else { + 0 + } + val expiryTimestamp = if (isTrashAlbum && SdkCompat.supportsTrash) { + it.tryGetLong(indexCache[i]) + } else { + null + } + val contentUri = if (mimeType.contains("image")) { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + val uri = ContentUris.withAppendedId(contentUri, id) + val formattedDate = (takenTimestamp?.div(1000) ?: modifiedTimestamp).getDate( + Constants.FULL_DATE_FORMAT, + ) + Media.UriMedia( + id = id, + label = title, + uri = uri, + path = path, + relativePath = relativePath, + albumID = albumID, + albumLabel = albumLabel ?: Build.MODEL, + timestamp = modifiedTimestamp, + takenTimestamp = takenTimestamp, + expiryTimestamp = expiryTimestamp, + fullDate = formattedDate, + duration = duration, + favorite = isFavorite, + trashed = isTrashed, + size = size, + mimeType = mimeType, + ) } - ) { it, indexCache -> - var i = 0 - - val id = it.getLong(indexCache[i++]) - val path = it.getString(indexCache[i++]) - val relativePath = it.getString(indexCache[i++]) - val title = it.getString(indexCache[i++]) - val albumID = it.getLong(indexCache[i++]) - val albumLabel = it.tryGetString(indexCache[i++], Build.MODEL) - val takenTimestamp = it.tryGetLong(indexCache[i++]) - val modifiedTimestamp = it.getLong(indexCache[i++]) - val duration = it.tryGetString(indexCache[i++]) - val size = it.getLong(indexCache[i++]) - val mimeType = it.getString(indexCache[i++]) - // IS_FAVORITE and IS_TRASHED are only available on API 30+ - val isFavorite = if (SdkCompat.supportsFavorites) it.getInt(indexCache[i++]) else 0 - val isTrashAlbum = buckedId == MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id - val isTrashed = if (SdkCompat.supportsTrash) it.getInt(indexCache[if (isTrashAlbum) i++ else i]) else 0 - val expiryTimestamp = if (isTrashAlbum && SdkCompat.supportsTrash) it.tryGetLong(indexCache[i]) else null - val contentUri = if (mimeType.contains("image")) - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - else - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - val uri = ContentUris.withAppendedId(contentUri, id) - val formattedDate = (takenTimestamp?.div(1000) ?: modifiedTimestamp).getDate(Constants.FULL_DATE_FORMAT) - Media.UriMedia( - id = id, - label = title, - uri = uri, - path = path, - relativePath = relativePath, - albumID = albumID, - albumLabel = albumLabel ?: Build.MODEL, - timestamp = modifiedTimestamp, - takenTimestamp = takenTimestamp, - expiryTimestamp = expiryTimestamp, - fullDate = formattedDate, - duration = duration, - favorite = isFavorite, - trashed = isTrashed, - size = size, - mimeType = mimeType - ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaUriFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaUriFlow.kt index 92923f4efb..e094d16ce6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaUriFlow.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/queries/MediaUriFlow.kt @@ -25,13 +25,15 @@ import com.dot.gallery.core.util.ext.tryGetLong import com.dot.gallery.core.util.ext.tryGetString import com.dot.gallery.core.util.join import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaType -import com.dot.gallery.feature_node.domain.util.isTrashed +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaType +import com.dot.gallery.feature_node.data.util.isTrashed import com.dot.gallery.feature_node.presentation.util.getDate +import com.dot.gallery.feature_node.presentation.util.parseTimestampFromFilename +import com.dot.gallery.feature_node.presentation.util.printWarning import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import com.dot.gallery.feature_node.presentation.util.printWarning /** * Media uri flow @@ -51,8 +53,19 @@ class MediaUriFlow( ) : QueryFlow() { private var buckedId: Long = MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id + private val mediaStoreIds: List by lazy { + uris + .mapNotNull { uri -> + parseCandidateId(uri) + } + .distinct() + } override fun flowCursor(): Flow { + if (onlyMatchingUris && mediaStoreIds.isEmpty()) { + return flowOf(null) + } + val uri = MediaQuery.MediaStoreFileUri val projection = MediaQuery.MediaProjection val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let { @@ -83,6 +96,15 @@ class MediaUriFlow( MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG } + val mediaStoreIdFilter = when { + onlyMatchingUris -> { + val placeholders = mediaStoreIds.joinToString(separator = ",") { Query.ARG } + "${MediaStore.Files.FileColumns._ID} IN ($placeholders)" + } + + else -> null + } + // Join all the non-null queries val selection = listOfNotNull( imageOrVideo, @@ -90,12 +112,26 @@ class MediaUriFlow( mimeTypeQuery, ).join(Query::and) - val selectionArgs = listOfNotNull( + val sqlSelection = listOfNotNull( + selection?.build(), + mediaStoreIdFilter, + ) + .takeIf { it.isNotEmpty() } + ?.joinToString(separator = " AND ") { "($it)" } + + val selectionArgs = buildList { buckedId.takeIf { MediaStoreBuckets.entries.toTypedArray().none { bucket -> it == bucket.id } - }?.toString(), - rawMimeType, - ).toTypedArray() + } + ?.toString() + ?.let(::add) + + rawMimeType?.let(::add) + + if (onlyMatchingUris) { + mediaStoreIds.map { it.toString() }.let(::addAll) + } + }.toTypedArray() val sortOrder = when (buckedId) { MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> @@ -106,7 +142,7 @@ class MediaUriFlow( } val queryArgs = Bundle().apply { - putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection?.build()) + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, sqlSelection) putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs) putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder) @@ -118,7 +154,7 @@ class MediaUriFlow( MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY else -> MediaStore.MATCH_EXCLUDE - } + }, ) } } @@ -130,8 +166,8 @@ class MediaUriFlow( ) } - override fun flowData() = - flowCursor().mapEachRow(MediaQuery.MediaProjection) { it, indexCache -> + override fun flowData(): Flow> { + return flowCursor().mapEachRow(MediaQuery.MediaProjection) { it, indexCache -> var i = 0 val id = it.getLong(indexCache[i++]) @@ -141,6 +177,7 @@ class MediaUriFlow( val albumID = it.getLong(indexCache[i++]) val albumLabel = it.tryGetString(indexCache[i++], Build.MODEL) val takenTimestamp = it.tryGetLong(indexCache[i++]) + ?: title.parseTimestampFromFilename() val modifiedTimestamp = it.getLong(indexCache[i++]) val duration = it.tryGetString(indexCache[i++]) val size = it.getLong(indexCache[i++]) @@ -148,12 +185,16 @@ class MediaUriFlow( // IS_FAVORITE and IS_TRASHED are only available on API 30+ val isFavorite = if (SdkCompat.supportsFavorites) it.getInt(indexCache[i++]) else 0 val isTrashed = if (SdkCompat.supportsTrash) it.getInt(indexCache[i]) else 0 - val contentUri = if (mimeType.contains("image")) + val contentUri = if (mimeType.contains("image")) { MediaStore.Images.Media.EXTERNAL_CONTENT_URI - else + } else { MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } val uri = ContentUris.withAppendedId(contentUri, id) - val formattedDate = (takenTimestamp?.div(1000) ?: modifiedTimestamp).getDate(Constants.FULL_DATE_FORMAT) + val formattedDate = (takenTimestamp?.div(1000) ?: modifiedTimestamp).getDate( + Constants.FULL_DATE_FORMAT, + ) + Media.UriMedia( id = id, label = title, @@ -170,18 +211,16 @@ class MediaUriFlow( favorite = isFavorite, trashed = isTrashed, size = size, - mimeType = mimeType + mimeType = mimeType, ) }.let { flow -> - // Derive candidate media IDs from provided URIs. These may be either - // MediaStore content:// URIs or file:// URIs that represent encrypted - // vault files whose filenames follow the pattern .enc - val ids: List = uris.mapNotNull { uri -> - parseCandidateId(uri) - }.distinct() + // Derive candidate media IDs from provided MediaStore content:// URIs. + val ids = mediaStoreIds if (onlyMatchingUris) { flow.map { mediaList -> - mediaList.filter { media -> ids.contains(media.id) && !media.isTrashed } + mediaList + .filter { media -> ids.contains(media.id) && !media.isTrashed } + .sortedBy { media -> ids.indexOf(media.id) } } } else { val bucketId = getBucketIdFromFirstUri() @@ -194,12 +233,12 @@ class MediaUriFlow( } } } + } private fun getBucketIdFromFirstUri(): Long? { val firstUri = uris.firstOrNull() ?: return null - // Bucket lookup only makes sense for MediaStore content URIs. File based - // (encrypted) URIs won't have a bucket; skip early. - if (firstUri.scheme != ContentResolver.SCHEME_CONTENT) return null + // Bucket lookup only makes sense for MediaStore content URIs. + if (!isMediaStoreContentUri(firstUri)) return null val id = try { ContentUris.parseId(firstUri) } catch (e: NumberFormatException) { @@ -214,7 +253,7 @@ class MediaUriFlow( projection, selection, selectionArgs, - null + null, )?.use { cursor -> if (cursor.moveToFirst()) { return cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID)) @@ -225,34 +264,24 @@ class MediaUriFlow( /** * Attempt to derive a stable numeric media ID from a supplied URI. - * - For content:// URIs we delegate to [ContentUris.parseId]. - * - For file:// URIs pointing to encrypted vault files we strip a trailing - * ".enc" extension and parse the remaining filename as a Long. + * - For MediaStore content:// URIs we delegate to [ContentUris.parseId]. * Returns null (instead of a random fabricated ID) if parsing fails so the - * caller can simply exclude the unmatched entry. This prevents accidental - * association with unrelated media rows and avoids decryption failures due - * to ID skew. + * caller can simply exclude the unmatched entry. */ private fun parseCandidateId(uri: Uri): Long? { - return if (uri.scheme == ContentResolver.SCHEME_CONTENT) { - try { - ContentUris.parseId(uri) - } catch (e: NumberFormatException) { - // Unexpected malformed content URI; exclude. - printWarning("MediaUriFlow: Failed to parse content URI id: $uri -> ${e.message}") - null - } - } else { - // file:// or other scheme; check for encrypted vault naming pattern - val name = uri.lastPathSegment ?: return null - val numericPart = if (name.endsWith(".enc", ignoreCase = true)) { - name.removeSuffix(".enc") - } else name - numericPart.toLongOrNull().also { parsed -> - if (parsed == null) { - printWarning("MediaUriFlow: Unable to derive id from URI filename '$name'") - } - } + if (!isMediaStoreContentUri(uri)) { + return null + } + return try { + ContentUris.parseId(uri) + } catch (e: NumberFormatException) { + printWarning("MediaUriFlow: Failed to parse content URI id: $uri -> ${e.message}") + null } } -} \ No newline at end of file + + private fun isMediaStoreContentUri(uri: Uri): Boolean { + return uri.scheme == ContentResolver.SCHEME_CONTENT && + uri.authority == MediaStore.AUTHORITY + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/migration/IgnoredAlbumMigration.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/migration/IgnoredAlbumMigration.kt deleted file mode 100644 index d5574d94cc..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/migration/IgnoredAlbumMigration.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_source.migration - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -/** - * Migration from version 12 to 13 for the ignored albums feature. - * - * Changes: - * - label column is now nullable - * - Added albumIds column with default '[]' - * - Re-generate labels based on type (Single/Multiple/Regex) - */ -val MIGRATION_12_13 = object : Migration(12, 13) { - override fun migrate(db: SupportSQLiteDatabase) { - // Create temporary table with new schema - db.execSQL(""" - CREATE TABLE IF NOT EXISTS blacklist_new ( - id INTEGER NOT NULL PRIMARY KEY, - label TEXT, - wildcard TEXT, - albumIds TEXT NOT NULL DEFAULT '[]', - location INTEGER NOT NULL DEFAULT 0, - matchedAlbums TEXT NOT NULL DEFAULT '[]' - ) - """) - - // Copy data from old table to new table - db.execSQL(""" - INSERT INTO blacklist_new (id, label, wildcard, location, matchedAlbums) - SELECT id, label, wildcard, location, matchedAlbums FROM blacklist - """) - - // Drop old table - db.execSQL("DROP TABLE blacklist") - - // Rename new table to original name - db.execSQL("ALTER TABLE blacklist_new RENAME TO blacklist") - - // Now regenerate labels based on type - regenerateLabels(db) - } - - private fun regenerateLabels(db: SupportSQLiteDatabase) { - // Get all ignored albums - val cursor = db.query("SELECT id, label, wildcard, albumIds FROM blacklist") - - val singleLabels = mutableListOf() - val multipleLabels = mutableListOf() - val regexLabels = mutableListOf() - - val albumsToUpdate = mutableListOf() - - cursor.use { - while (it.moveToNext()) { - val id = it.getLong(it.getColumnIndexOrThrow("id")) - val oldLabel = it.getString(it.getColumnIndexOrThrow("label")) - val wildcard = it.getString(it.getColumnIndexOrThrow("wildcard")) - val albumIds = it.getString(it.getColumnIndexOrThrow("albumIds")) - - val type = when { - wildcard != null && wildcard.isNotEmpty() -> "Regex" - albumIds != "[]" && albumIds.isNotEmpty() -> "Multiple" - else -> "Single" - } - - val existingLabels = when (type) { - "Single" -> singleLabels - "Multiple" -> multipleLabels - "Regex" -> regexLabels - else -> singleLabels - } - - // Check if current label is valid (matches pattern and not duplicate) - val isValidLabel = oldLabel?.let { label -> - label.startsWith("$type #") && label !in existingLabels - } ?: false - - val newLabel = if (isValidLabel) { - oldLabel!! - } else { - generateUniqueLabel(type, existingLabels) - } - - existingLabels.add(newLabel) - - if (oldLabel != newLabel) { - albumsToUpdate.add(AlbumLabelUpdate(id, newLabel)) - } - } - } - - // Update labels - albumsToUpdate.forEach { update -> - val contentValues = ContentValues().apply { - put("label", update.newLabel) - } - db.update("blacklist", SQLiteDatabase.CONFLICT_REPLACE, contentValues, "id = ?", arrayOf(update.id)) - } - } - - private fun generateUniqueLabel(type: String, existingLabels: List): String { - var labelNumber = existingLabels.size + 1 - var generatedLabel = "$type #$labelNumber" - while (generatedLabel in existingLabels) { - labelNumber++ - generatedLabel = "$type #$labelNumber" - } - return generatedLabel - } -} - -private data class AlbumLabelUpdate(val id: Long, val newLabel: String) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParser.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParser.kt new file mode 100644 index 0000000000..f503ff44bf --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParser.kt @@ -0,0 +1,191 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import android.app.ComponentCaller +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.core.content.IntentCompat +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import javax.inject.Inject + +internal interface ExternalCropIntentParser { + + fun parse( + intent: Intent, + caller: ComponentCaller, + ): ExternalCropRequest? + + companion object { + const val ACTION_CROP = "com.android.camera.action.CROP" + } +} + +internal class ExternalCropIntentParserImpl @Inject constructor( + private val uriPermissionChecker: ExternalCropUriPermissionChecker, +) : ExternalCropIntentParser { + + override fun parse( + intent: Intent, + caller: ComponentCaller, + ): ExternalCropRequest? { + return intent + .takeIf { + intent.action == ExternalCropIntentParser.ACTION_CROP + } + ?.let { + getSupportedSourceUri( + intent = intent, + caller = caller, + ) + } + ?.let { sourceUri -> + createExternalCropRequestForSupportedSource( + intent = intent, + caller = caller, + sourceUri = sourceUri, + ) + } + } + + private fun getSupportedSourceUri( + intent: Intent, + caller: ComponentCaller, + ): Uri? { + return intent + .data + ?.takeIf { uri -> + isSupportedExternalCropSource( + uri = uri, + caller = caller, + ) + } + } + + private fun getRequestedOutputUri(intent: Intent): Uri? { + return when { + hasOutputUriExtra(intent = intent) -> getOutputUriExtra(intent = intent) + else -> null + } + } + + private fun hasOutputUriExtra(intent: Intent): Boolean { + return intent.hasExtra(MediaStore.EXTRA_OUTPUT) + } + + private fun isValidRequestedOutputUri( + intent: Intent, + outputUri: Uri?, + caller: ComponentCaller, + ): Boolean { + return when { + !hasOutputUriExtra(intent = intent) -> true + outputUri == null -> false + else -> isSupportedExternalCropOutput( + uri = outputUri, + caller = caller, + ) + } + } + + private fun createExternalCropRequestForSupportedSource( + intent: Intent, + caller: ComponentCaller, + sourceUri: Uri, + ): ExternalCropRequest? { + val outputUri = getRequestedOutputUri(intent = intent) + + return when { + isValidRequestedOutputUri( + intent = intent, + outputUri = outputUri, + caller = caller, + ) -> { + createExternalCropRequest( + intent = intent, + sourceUri = sourceUri, + outputUri = outputUri, + ) + } + + else -> null + } + } + + private fun createExternalCropRequest( + intent: Intent, + sourceUri: Uri, + outputUri: Uri?, + ): ExternalCropRequest { + return ExternalCropRequest( + sourceUri = sourceUri, + outputUri = outputUri, + outputX = intent.getIntExtra(EXTRA_OUTPUT_X, 0), + outputY = intent.getIntExtra(EXTRA_OUTPUT_Y, 0), + scale = intent.getBooleanExtra(EXTRA_SCALE, true), + scaleUpIfNeeded = intent.getBooleanExtra(EXTRA_SCALE_UP_IF_NEEDED, false), + aspectX = intent.getIntExtra(EXTRA_ASPECT_X, 0), + aspectY = intent.getIntExtra(EXTRA_ASPECT_Y, 0), + returnData = intent.getBooleanExtra(EXTRA_RETURN_DATA, false), + outputFormat = intent.getStringExtra(EXTRA_OUTPUT_FORMAT), + ) + } + + private fun getOutputUriExtra(intent: Intent): Uri? { + return IntentCompat.getParcelableExtra( + intent, + MediaStore.EXTRA_OUTPUT, + Uri::class.java, + ) + } + + private fun isSupportedExternalCropSource( + uri: Uri, + caller: ComponentCaller, + ): Boolean { + return when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + uriPermissionChecker.canReadContentUri( + uri = uri, + caller = caller, + ) + } + + ContentResolver.SCHEME_FILE -> { + uriPermissionChecker.canReadFileUri( + uri = uri, + caller = caller, + ) + } + + else -> false + } + } + + private fun isSupportedExternalCropOutput( + uri: Uri, + caller: ComponentCaller, + ): Boolean { + return when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + uriPermissionChecker.canWriteContentUri( + uri = uri, + caller = caller, + ) + } + + else -> false + } + } + + companion object { + private const val EXTRA_ASPECT_X = "aspectX" + private const val EXTRA_ASPECT_Y = "aspectY" + private const val EXTRA_OUTPUT_FORMAT = "outputFormat" + private const val EXTRA_OUTPUT_X = "outputX" + private const val EXTRA_OUTPUT_Y = "outputY" + private const val EXTRA_RETURN_DATA = "return-data" + private const val EXTRA_SCALE = "scale" + private const val EXTRA_SCALE_UP_IF_NEEDED = "scaleUpIfNeeded" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropResultIntent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropResultIntent.kt new file mode 100644 index 0000000000..f722c3bc0f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropResultIntent.kt @@ -0,0 +1,26 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import com.dot.gallery.feature_node.data.model.editor.crop.CropPixelRect + +private const val EXTRA_CROPPED_RECT = "cropped-rect" +private const val EXTRA_DATA = "data" + +internal fun buildCropResultIntent( + croppedRect: CropPixelRect, + outputUri: Uri?, + returnDataBitmap: Bitmap?, +): Intent { + return Intent().apply { + putExtra(EXTRA_CROPPED_RECT, croppedRect.toAndroidRect()) + outputUri?.let { uri -> + data = uri + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + returnDataBitmap?.let { bitmap -> + putExtra(EXTRA_DATA, bitmap) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizing.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizing.kt new file mode 100644 index 0000000000..8c324e2078 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizing.kt @@ -0,0 +1,222 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import kotlin.math.roundToInt +import kotlin.math.sqrt + +private const val ARGB_BYTES_PER_PIXEL = 4 +private const val MAX_BITMAP_BYTES_IN_RESULT = 750_000 +private const val MAX_CROP_OUTPUT_AREA_SIDE = 4096 +private const val MAX_CROP_OUTPUT_DIMENSION = 8192 +private const val MAX_CROP_OUTPUT_PIXELS = + MAX_CROP_OUTPUT_AREA_SIDE * MAX_CROP_OUTPUT_AREA_SIDE + +internal data class CropSize( + val width: Int, + val height: Int, +) + +internal fun resolveCropOutputSize( + cropWidth: Int, + cropHeight: Int, + outputX: Int, + outputY: Int, + scale: Boolean, + scaleUpIfNeeded: Boolean, +): CropSize { + return when { + isInvalidCropSize( + width = cropWidth, + height = cropHeight, + ) -> minimumCropSize() + + !hasRequestedOutput( + outputX = outputX, + outputY = outputY, + ) -> { + CropSize( + width = cropWidth, + height = cropHeight, + ) + } + + !scale -> { + clampCropOutputSize( + width = outputX, + height = outputY, + ) + } + + else -> { + resolveRequestedCropOutputSize( + cropSize = CropSize( + width = cropWidth, + height = cropHeight, + ), + outputX = outputX, + outputY = outputY, + scaleUpIfNeeded = scaleUpIfNeeded, + ) + } + } +} + +private fun isInvalidCropSize(width: Int, height: Int): Boolean { + return width <= 0 || height <= 0 +} + +private fun minimumCropSize(): CropSize { + return CropSize( + width = 1, + height = 1, + ) +} + +private fun hasRequestedOutput( + outputX: Int, + outputY: Int, +): Boolean { + return outputX > 0 && outputY > 0 +} + +private fun resolveRequestedCropOutputSize( + cropSize: CropSize, + outputX: Int, + outputY: Int, + scaleUpIfNeeded: Boolean, +): CropSize { + val requestedSize = clampCropOutputSize( + width = outputX, + height = outputY, + ) + return when { + scaleUpIfNeeded -> requestedSize + else -> { + downscaleCropSizeToRequestedBounds( + cropSize = cropSize, + requestedSize = requestedSize, + ) + } + } +} + +private fun downscaleCropSizeToRequestedBounds( + cropSize: CropSize, + requestedSize: CropSize, +): CropSize { + val scaleFactor = minOf( + 1.0, + requestedSize.width.toDouble() / cropSize.width.toDouble(), + requestedSize.height.toDouble() / cropSize.height.toDouble(), + ) + return scaleCropSize( + cropSize = cropSize, + scaleFactor = scaleFactor, + ) +} + +private fun scaledDimension(dimension: Int, scaleFactor: Double): Int { + return (dimension.toDouble() * scaleFactor) + .roundToInt() + .coerceAtLeast(minimumValue = 1) +} + +internal fun resolveCropSourceDecodeSize( + sourceWidth: Int, + sourceHeight: Int, +): CropSize { + return when { + isInvalidCropSize( + width = sourceWidth, + height = sourceHeight, + ) -> minimumCropSize() + + else -> { + clampCropOutputSize( + width = sourceWidth, + height = sourceHeight, + ) + } + } +} + +private fun clampCropOutputSize(width: Int, height: Int): CropSize { + return positiveCropSize( + width = width, + height = height, + ) + .let(::clampCropSizeToMaxDimensions) + .let(::clampCropSizeToMaxPixels) +} + +private fun positiveCropSize(width: Int, height: Int): CropSize { + return CropSize( + width = width.coerceAtLeast(minimumValue = 1), + height = height.coerceAtLeast(minimumValue = 1), + ) +} + +private fun clampCropSizeToMaxDimensions(cropSize: CropSize): CropSize { + val scaleFactor = minOf( + 1.0, + MAX_CROP_OUTPUT_DIMENSION.toDouble() / cropSize.width.toDouble(), + MAX_CROP_OUTPUT_DIMENSION.toDouble() / cropSize.height.toDouble(), + ) + + return scaleCropSize( + cropSize = cropSize, + scaleFactor = scaleFactor, + ) +} + +private fun clampCropSizeToMaxPixels(cropSize: CropSize): CropSize { + val pixelCount = cropPixelCount(cropSize = cropSize) + + return when { + pixelCount <= MAX_CROP_OUTPUT_PIXELS.toLong() -> cropSize + else -> { + scaleCropSize( + cropSize = cropSize, + scaleFactor = sqrt(MAX_CROP_OUTPUT_PIXELS.toDouble() / pixelCount.toDouble()), + ) + } + } +} + +private fun cropPixelCount(cropSize: CropSize): Long { + return cropSize.width.toLong() * cropSize.height.toLong() +} + +private fun scaleCropSize(cropSize: CropSize, scaleFactor: Double): CropSize { + return CropSize( + width = scaledDimension( + dimension = cropSize.width, + scaleFactor = scaleFactor, + ), + height = scaledDimension( + dimension = cropSize.height, + scaleFactor = scaleFactor, + ), + ) +} + +internal fun resolveIntentBitmapSize( + width: Int, + height: Int, +): CropSize { + var targetWidth = width.coerceAtLeast(minimumValue = 1) + var targetHeight = height.coerceAtLeast(minimumValue = 1) + + while (targetWidth.toLong() * targetHeight.toLong() * ARGB_BYTES_PER_PIXEL > + MAX_BITMAP_BYTES_IN_RESULT.toLong() && + targetWidth > 1 && + targetHeight > 1 + ) { + targetWidth = (targetWidth / 2).coerceAtLeast(minimumValue = 1) + targetHeight = (targetHeight / 2).coerceAtLeast(minimumValue = 1) + } + + return CropSize( + width = targetWidth, + height = targetHeight, + ) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropUriPermissionChecker.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropUriPermissionChecker.kt new file mode 100644 index 0000000000..a9069ed1c8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropUriPermissionChecker.kt @@ -0,0 +1,265 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import android.Manifest +import android.app.ComponentCaller +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Environment +import android.os.Process +import android.provider.MediaStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject + +internal interface ExternalCropUriPermissionChecker { + + fun canReadContentUri( + uri: Uri, + caller: ComponentCaller, + ): Boolean + + fun canWriteContentUri(uri: Uri, caller: ComponentCaller): Boolean + + fun canReadFileUri( + uri: Uri, + caller: ComponentCaller, + ): Boolean +} + +internal class ComponentCallerExternalCropUriPermissionChecker @Inject constructor( + @param:ApplicationContext private val context: Context, + private val packageManager: PackageManager, +) : ExternalCropUriPermissionChecker { + + override fun canReadContentUri( + uri: Uri, + caller: ComponentCaller, + ): Boolean { + return hasContentUriPermission( + caller = caller, + uri = uri, + modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + + /** + * The caller must be able to write the output itself. We must never write where the caller could + * not: the crop ui only ever shows the source, so a user confirming the crop is not consenting + * to the destination, and a hostile caller pointing the output at media it cannot write would + * make us its deputy. + * + * Which api can establish that depends on the authority. + * + * For a non-MediaStore output, [ComponentCaller.checkContentUriPermission] answers, provided the + * caller also passed the uri via `Intent#getData`, `EXTRA_STREAM` or `Intent#getClipData` with a + * write grant — that is the launch grant set the method consults. AvatarPicker does exactly + * that: it puts one uri from its own `FileProvider` in both the intent data and + * [android.provider.MediaStore.EXTRA_OUTPUT], with + * [Intent.FLAG_GRANT_WRITE_URI_PERMISSION]. An output uri that arrives *only* as the plain + * extra is outside the launch grant set, the platform throws [IllegalArgumentException], and we + * reject — the caller proved nothing, so that is the right answer rather than a shortcoming. + * + * For a MediaStore output the launch grant api genuinely cannot answer, because legacy callers + * pass media uris in the extra alone. Uid-based [Context.checkUriPermission] is no substitute + * either: it consults explicit uri grants and the provider's manifest permission only, not + * MediaProvider's ownership model, so it denies media uris even for our own uid. So ownership is + * resolved directly, from [android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME]. This + * deliberately narrows the legacy `ACTION_CROP` contract: an output row owned by a third party, + * or with no recorded owner (a legacy file picked up by the media scanner), is refused, and no + * generic uri check can widen it back. + */ + override fun canWriteContentUri( + uri: Uri, + caller: ComponentCaller, + ): Boolean { + if (caller.uid == Process.myUid()) { + return true + } + + return when { + isMediaStoreUri(uri) -> isReachable(uri) && isOwnedByCallerOrUs(uri, caller) + + else -> hasContentUriPermission( + caller = caller, + uri = uri, + modeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + + private fun isOwnedByCallerOrUs(uri: Uri, caller: ComponentCaller): Boolean { + val owner = getOwnerPackageName(uri)?.takeIf { it.isNotBlank() } ?: return false + + return owner == context.packageName || owner in getCallerPackageNames(caller) + } + + private fun getOwnerPackageName(uri: Uri): String? { + return try { + context.contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.OWNER_PACKAGE_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (_: SecurityException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + + private fun getCallerPackageNames(caller: ComponentCaller): Set { + val uidPackages = packageManager.getPackagesForUid(caller.uid)?.toSet().orEmpty() + + return uidPackages + setOfNotNull(caller.getPackage()) + } + + private fun isMediaStoreUri(uri: Uri): Boolean { + return uri.authority == MediaStore.AUTHORITY + } + + /** + * Deliberately probes with [ContentResolver.getType] rather than opening the descriptor: `"w"` + * truncates the target on some providers, which would destroy the output before the user has + * even confirmed the crop. A write that fails later is handled by the save path, which returns + * no result intent and cancels. + */ + private fun isReachable(uri: Uri): Boolean { + return try { + context.contentResolver.getType(uri) != null + } catch (_: SecurityException) { + false + } catch (_: IllegalArgumentException) { + false + } + } + + override fun canReadFileUri( + uri: Uri, + caller: ComponentCaller, + ): Boolean { + return canReadExternalImages(caller) && isAllowedExternalCropFileSource(uri) + } + + private fun hasContentUriPermission( + caller: ComponentCaller, + uri: Uri, + modeFlags: Int, + ): Boolean { + if (caller.uid == Process.myUid()) { + return true + } + + return try { + caller.checkContentUriPermission(uri, modeFlags) == PackageManager.PERMISSION_GRANTED + } catch (_: IllegalArgumentException) { + false + } catch (_: SecurityException) { + false + } + } + + private fun canReadExternalImages(caller: ComponentCaller): Boolean { + if (caller.uid == Process.myUid()) { + return true + } + + val callerPackageName = caller.getPackage() ?: return false + + return when { + hasReadMediaImagesPermission(callerPackageName) -> true + hasReadExternalStoragePermission(callerPackageName) -> true + hasManageExternalStoragePermission(callerPackageName) -> true + + else -> false + } + } + + private fun hasReadMediaImagesPermission(callerPackageName: String): Boolean { + return hasPackagePermission( + packageName = callerPackageName, + permission = Manifest.permission.READ_MEDIA_IMAGES, + ) + } + + private fun hasReadExternalStoragePermission(callerPackageName: String): Boolean { + return hasPackagePermission( + packageName = callerPackageName, + permission = Manifest.permission.READ_EXTERNAL_STORAGE, + ) + } + + private fun hasManageExternalStoragePermission(callerPackageName: String): Boolean { + return hasPackagePermission( + packageName = callerPackageName, + permission = Manifest.permission.MANAGE_EXTERNAL_STORAGE, + ) + } + + private fun isAllowedExternalCropFileSource(uri: Uri): Boolean { + return uri + .path + .takeIf { uri.scheme == ContentResolver.SCHEME_FILE } + ?.let(::isAllowedExternalCropFileSourcePath) + ?: false + } + + private fun isAllowedExternalCropFileSourcePath(path: String): Boolean { + return getCanonicalFile(path) + ?.let(::isInExternalCropFileSourceDirectory) + ?: false + } + + private fun getCanonicalFile(path: String): File? { + return try { + File(path).canonicalFile + } catch (_: Exception) { + null + } + } + + private fun isInExternalCropFileSourceDirectory(sourceFile: File): Boolean { + return getExternalCropFileSourceDirectories().any { directory -> + isSameOrChildOf( + sourceFile = sourceFile, + parent = directory, + ) + } + } + + private fun getExternalCropFileSourceDirectories(): List { + val directories = listOf( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + ) + + return directories.mapNotNull { directory -> + try { + directory.canonicalFile + } catch (_: Exception) { + null + } + } + } + + private fun isSameOrChildOf(sourceFile: File, parent: File): Boolean { + val parentPath = parent.path.trimEnd(File.separatorChar) + val filePath = sourceFile.path + return filePath == parentPath || filePath.startsWith("$parentPath${File.separator}") + } + + private fun hasPackagePermission( + packageName: String, + permission: String, + ): Boolean { + return packageManager.checkPermission(permission, packageName) == + PackageManager.PERMISSION_GRANTED + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AiMediaAnalysisPreferences.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AiMediaAnalysisPreferences.kt new file mode 100644 index 0000000000..b78cbc738f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AiMediaAnalysisPreferences.kt @@ -0,0 +1,8 @@ +package com.dot.gallery.feature_node.data.model + +internal data class AiMediaAnalysisPreferences( + val analysisEnabled: Boolean, + val categoryClassificationEnabled: Boolean, + val analysisCleanupPending: Boolean, + val categoryCleanupPending: Boolean, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Album.kt similarity index 90% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Album.kt index 29c5fe5446..88fb0eec1e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Album.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import android.net.Uri import android.os.Parcelable @@ -40,6 +40,10 @@ data class Album( @Stable val volume: String = pathToThumbnail.substringBeforeLast("/").removeSuffix(relativePath.removeSuffix("/")) + @IgnoredOnParcel + @Stable + val absolutePath: String = volume.trimEnd('/') + "/" + relativePath + @IgnoredOnParcel @Stable val isOnSdcard: Boolean = diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroup.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroup.kt similarity index 89% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroup.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroup.kt index 40220f25f1..b4c6f000ed 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroup.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroup.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupMember.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroupMember.kt similarity index 92% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupMember.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroupMember.kt index 5df0f163a2..75e1b9bd0d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupMember.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumGroupMember.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumThumbnail.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumThumbnail.kt similarity index 72% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumThumbnail.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumThumbnail.kt index 52c0c284a7..fd11b1b9d6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumThumbnail.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/AlbumThumbnail.kt @@ -1,8 +1,8 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import android.net.Uri import androidx.room.Entity -import com.dot.gallery.feature_node.domain.util.UriSerializer +import com.dot.gallery.feature_node.data.util.UriSerializer import kotlinx.serialization.Serializable @Serializable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Category.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Category.kt similarity index 99% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Category.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Category.kt index d9b9a1dcc2..b52074245c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Category.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Category.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CategoryWithMediaCount.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CategoryWithMediaCount.kt new file mode 100644 index 0000000000..161b99a671 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CategoryWithMediaCount.kt @@ -0,0 +1,49 @@ +package com.dot.gallery.feature_node.data.model + +data class CategoryWithMediaCount( + val id: Long, + val name: String, + val searchTerms: String, + val embedding: FloatArray?, + val referenceImageIds: List, + val threshold: Float, + val isUserCreated: Boolean, + val isPinned: Boolean, + val createdAt: Long, + val updatedAt: Long, + val mediaCount: Int, + val thumbnailMediaId: Long?, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CategoryWithMediaCount) return false + return id == other.id && + name == other.name && + searchTerms == other.searchTerms && + embedding.contentEquals(other.embedding) && + referenceImageIds == other.referenceImageIds && + threshold == other.threshold && + isUserCreated == other.isUserCreated && + isPinned == other.isPinned && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + mediaCount == other.mediaCount && + thumbnailMediaId == other.thumbnailMediaId + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + searchTerms.hashCode() + result = 31 * result + (embedding?.contentHashCode() ?: 0) + result = 31 * result + referenceImageIds.hashCode() + result = 31 * result + threshold.hashCode() + result = 31 * result + isUserCreated.hashCode() + result = 31 * result + isPinned.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + updatedAt.hashCode() + result = 31 * result + mediaCount + result = 31 * result + (thumbnailMediaId?.hashCode() ?: 0) + return result + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Collection.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Collection.kt new file mode 100644 index 0000000000..9c5dbb5091 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Collection.kt @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ +package com.dot.gallery.feature_node.data.model + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable + +@Entity(tableName = "collections") +@Immutable +@Serializable +data class Collection( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val label: String, + val coverMediaId: Long? = null, + val isPinned: Boolean = false, + val sortOrder: Int = 0, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) + +/** + * A collection row plus its resolved thumbnail, as returned by + * `CollectionDao.getCollectionsWithCount`. The thumbnail column is computed by that query rather + * than stored: it is `coverMediaId` when set, otherwise the most recently added member. + */ +data class CollectionWithThumbnail( + @Embedded val collection: Collection, + val thumbnailMediaId: Long? +) + +/** + * Represents a collection with its associated media count and thumbnail, for display in the UI. + * + * [mediaCount] and [totalSize] are derived in the repository from the live media set rather than + * from SQL, because the `media` table is only the search index cache — it is populated by + * `SearchIndexerUpdaterWorker`, which never runs unless AI media analysis is enabled. Deriving them + * from live media also means members deleted outside the app drop out, so the card can never + * disagree with what opening the collection shows. + */ +data class CollectionWithCount( + @Embedded val collection: Collection, + val mediaCount: Int, + val thumbnailMediaId: Long?, + val totalSize: Long = 0 +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionAlbum.kt similarity index 94% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionAlbum.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionAlbum.kt index f0f3faa127..13ae1ba6cb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionAlbum.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionAlbum.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionMedia.kt similarity index 94% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionMedia.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionMedia.kt index 2269dacf4f..49b241f76e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/CollectionMedia.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/CollectionMedia.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/IgnoredAlbum.kt similarity index 96% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/IgnoredAlbum.kt index 089e4664e2..58d1ca5d36 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/IgnoredAlbum.kt @@ -1,11 +1,11 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.dot.gallery.feature_node.domain.util.volume +import com.dot.gallery.feature_node.data.util.volume import kotlinx.parcelize.Parcelize @Entity(tableName = "blacklist") diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/ImageEmbedding.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbedding.kt similarity index 94% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/ImageEmbedding.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbedding.kt index a6bda4964d..29518b414a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/ImageEmbedding.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbedding.kt @@ -1,4 +1,4 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.room.Entity import androidx.room.PrimaryKey @@ -31,4 +31,4 @@ data class ImageEmbedding( result = 31 * result + embedding.contentHashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbeddingStamp.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbeddingStamp.kt new file mode 100644 index 0000000000..ccd23e26a4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ImageEmbeddingStamp.kt @@ -0,0 +1,6 @@ +package com.dot.gallery.feature_node.data.model + +data class ImageEmbeddingStamp( + val id: Long, + val date: Long, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LockedAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/LockedAlbum.kt similarity index 86% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LockedAlbum.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/LockedAlbum.kt index b7a8fc17dd..590f1cc380 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LockedAlbum.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/LockedAlbum.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Media.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Media.kt new file mode 100644 index 0000000000..b1e1c2cdd7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/Media.kt @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.model + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Parcelable +import android.webkit.MimeTypeMap +import androidx.room.Entity +import com.dot.gallery.core.Constants +import com.dot.gallery.feature_node.data.util.UriSerializer +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.presentation.util.getDate +import java.io.File +import kotlin.random.Random +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Serialized polymorphically wherever a [Media] is persisted or passed between processes, keyed by + * the serial name of the concrete case. The names are pinned to the package this type used to live + * in so that payloads written by earlier versions still decode; they are a storage format and must + * not be updated to follow the class. + */ +@Serializable +@Parcelize +sealed class Media : Parcelable { + + + abstract val id: Long + abstract val label: String + abstract val path: String + abstract val relativePath: String + abstract val albumID: Long + abstract val albumLabel: String + abstract val timestamp: Long + abstract val expiryTimestamp: Long? + abstract val takenTimestamp: Long? + abstract val fullDate: String + abstract val mimeType: String + abstract val favorite: Int + abstract val trashed: Int + abstract val size: Long + abstract val duration: String? + + val definedTimestamp: Long + get() = takenTimestamp?.div(1000) ?: timestamp + + val key: String + get() = "{$id, ${try { getUri() } catch (_: Exception) { path} }, $definedTimestamp}" + + val idLessKey: String + get() = "{${try { getUri() } catch (_: Exception) { path} }, $definedTimestamp}" + + @Serializable + @SerialName("com.dot.gallery.feature_node.domain.model.Media.UriMedia") + @Parcelize + @Entity(tableName = "media", primaryKeys = ["id"]) + data class UriMedia( + override val id: Long = 0, + override val label: String, + @Serializable(with = UriSerializer::class) + val uri: Uri, + override val path: String, + override val relativePath: String, + override val albumID: Long, + override val albumLabel: String, + override val timestamp: Long, + override val expiryTimestamp: Long? = null, + override val takenTimestamp: Long? = null, + override val fullDate: String, + override val mimeType: String, + override val favorite: Int, + override val trashed: Int, + override val size: Long, + override val duration: String? = null + ) : Media() + + @Serializable + @SerialName("com.dot.gallery.feature_node.domain.model.Media.ClassifiedMedia") + @Parcelize + @Entity(tableName = "classified_media", primaryKeys = ["id"]) + data class ClassifiedMedia( + override val id: Long = 0, + override val label: String, + @Serializable(with = UriSerializer::class) + val uri: Uri, + override val path: String, + override val relativePath: String, + override val albumID: Long, + override val albumLabel: String, + override val timestamp: Long, + override val expiryTimestamp: Long?, + override val takenTimestamp: Long?, + override val fullDate: String, + override val mimeType: String, + override val favorite: Int, + override val trashed: Int, + override val size: Long, + override val duration: String?, + val category: String?, + val score: Float, + ): Media() + + companion object { + fun createFromUri(context: Context?, uri: Uri): UriMedia? { + if (uri.path == null) return null + val extension = uri.toString().substringAfterLast(".") + var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + var duration: String? = null + var timestamp = 0L + uri.path?.let { File(it) }?.let { + timestamp = try { + it.lastModified() + } catch (_: Exception) { + 0L + } + } + if (context != null) { + try { + val retriever = MediaMetadataRetriever().apply { + setDataSource(context, uri) + } + val hasVideo = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) + val isVideo = "yes" == hasVideo + if (isVideo) { + duration = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + if (timestamp == 0L) { + timestamp = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)?.toLongOrNull() ?: 0L + } + } + val originMimeType = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) + if (mimeType == null) { + mimeType = if (isVideo) originMimeType else "image/*" + } + } catch (e: Exception) { + e.printStackTrace() + } + } + var formattedDate = "" + if (timestamp != 0L) { + formattedDate = timestamp.getDate(Constants.EXTENDED_DATE_FORMAT) + } + return UriMedia( + id = Random(System.currentTimeMillis()).nextLong(-1000, 25600000), + label = uri.toString().substringAfterLast("/"), + uri = uri, + path = uri.path.toString(), + relativePath = uri.path.toString().substringBeforeLast("/"), + albumID = -99L, + albumLabel = "", + timestamp = timestamp, + fullDate = formattedDate, + mimeType = mimeType ?: "null", + duration = duration, + favorite = 0, + size = 0, + trashed = 0 + ) + } + } + +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaCategory.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaCategory.kt similarity index 97% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaCategory.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaCategory.kt index 15e31d7233..9f27d3c370 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaCategory.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaCategory.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.room.Entity import androidx.room.ForeignKey diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaItem.kt similarity index 94% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaItem.kt index 7cbf3339c1..d0767aad2e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaItem.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Stable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadata.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaMetadata.kt similarity index 92% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadata.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaMetadata.kt index e23b6bfee7..c484cf4033 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadata.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaMetadata.kt @@ -1,4 +1,4 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import android.content.ContentUris import android.content.Context @@ -19,20 +19,20 @@ import androidx.room.PrimaryKey import androidx.room.Relation import com.dot.gallery.core.sandbox.IsolatedMetadataParser import com.dot.gallery.core.sandbox.IsolatedMetadataService.Companion as Keys -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isImage -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isImage +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.util.formattedAddress import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.printWarning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable import java.math.RoundingMode import java.text.DecimalFormat import java.util.Locale import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable @Entity(tableName = "media_metadata_core") data class MediaMetadataCore( @@ -130,6 +130,16 @@ data class MediaMetadata( val isRelevant: Boolean get() = isNightMode || isPanorama || isPhotosphere || isLongExposure || isMotionPhoto + val searchableText: String + get() = buildString { + imageDescription?.let { append(it).append(' ') } + gpsLocationName?.let { append(it).append(' ') } + gpsLocationNameCity?.let { append(it).append(' ') } + gpsLocationNameCountry?.let { append(it).append(' ') } + manufacturerName?.let { append(it).append(' ') } + modelName?.let { append(it) } + } + val formattedCords: String? get() = if (gpsLatitude != null && gpsLongitude != null) String.format( Locale.getDefault(), "%.3f, %.3f", gpsLatitude, gpsLongitude @@ -191,20 +201,29 @@ fun MediaMetadata.getIcon(): ImageVector? { suspend fun Context.retrieveExtraMediaMetadata( isolatedParser: IsolatedMetadataParser, geocoder: Geocoder?, - media: Media + media: Media, + usePerFileIsolation: Boolean = false, ): MediaMetadata? = withContext(Dispatchers.IO) { runCatching { val uri = media.getUri() val label = media.label - printDebug("Retrieving extra metadata for ${media.id} - $uri") + printDebug("Retrieving extra metadata for ${media.id} - $uri (perFile=$usePerFileIsolation)") if (media.isImage) { - val bundle = isolatedParser.parseImageMetadata(uri, label) + val bundle = if (usePerFileIsolation) { + isolatedParser.parseImageMetadataPerFile(uri, label, media.id) + } else { + isolatedParser.parseImageMetadata(uri, label) + } ?: return@runCatching null mediaMetadataFromImageBundle(media.id, bundle, geocoder, this@retrieveExtraMediaMetadata) } else if (media.isVideo) { - val bundle = isolatedParser.parseVideoMetadata(uri) + val bundle = if (usePerFileIsolation) { + isolatedParser.parseVideoMetadataPerFile(uri, media.id) + } else { + isolatedParser.parseVideoMetadata(uri) + } ?: return@runCatching null mediaMetadataFromVideoBundle(media.id, bundle) } else { @@ -413,4 +432,4 @@ fun FullMediaMetadata.toMediaMetadata(): MediaMetadata { isLongExposure = f?.isLongExposure ?: false, isMotionPhoto = f?.isMotionPhoto ?: false ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaType.kt new file mode 100644 index 0000000000..a6983693e4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaType.kt @@ -0,0 +1,6 @@ +package com.dot.gallery.feature_node.data.model + +enum class MediaType { + IMAGE, + VIDEO, +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaVersion.kt similarity index 74% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaVersion.kt index 64314ffc67..8b77a2d1ad 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MediaVersion.kt @@ -1,4 +1,4 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MergedSubfolderAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MergedSubfolderAlbum.kt similarity index 87% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MergedSubfolderAlbum.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MergedSubfolderAlbum.kt index 3ab6e1b767..b4e853366b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MergedSubfolderAlbum.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/MergedSubfolderAlbum.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/PinnedAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/PinnedAlbum.kt similarity index 86% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/PinnedAlbum.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/PinnedAlbum.kt index 1b5618a657..5e869e4363 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/PinnedAlbum.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/PinnedAlbum.kt @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Immutable import androidx.room.Entity diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ScannedMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ScannedMedia.kt new file mode 100644 index 0000000000..41d3dcbd3d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/ScannedMedia.kt @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "scanned_media") +data class ScannedMedia( + @PrimaryKey(autoGenerate = false) + val id: Long, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/TimelineSettings.kt similarity index 87% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/model/TimelineSettings.kt index fde681e644..f014b3b246 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/TimelineSettings.kt @@ -1,11 +1,11 @@ -package com.dot.gallery.feature_node.domain.model +package com.dot.gallery.feature_node.data.model import androidx.compose.runtime.Stable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.OrderType /** * Timeline settings diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/WidgetData.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/WidgetData.kt new file mode 100644 index 0000000000..b806d1b8d8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/WidgetData.kt @@ -0,0 +1,16 @@ +package com.dot.gallery.feature_node.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class WidgetData( + val widgetId: Int, + val type: WidgetType, + val mediaUris: List, +) + +@Serializable +enum class WidgetType { + SINGLE, + GRID, +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/SaveFormat.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/SaveFormat.kt new file mode 100644 index 0000000000..95b9b4eccc --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/SaveFormat.kt @@ -0,0 +1,36 @@ +package com.dot.gallery.feature_node.data.model.editor + +import android.graphics.Bitmap.CompressFormat +import androidx.annotation.Keep + +@Keep +sealed interface SaveFormat { + + val compressFormat: CompressFormat + val fileExtension: String + val mimeType: String + + data object Png : SaveFormat { + override val compressFormat: CompressFormat = CompressFormat.PNG + override val fileExtension: String = "png" + override val mimeType: String = "image/png" + } + + data object Jpeg : SaveFormat { + override val compressFormat: CompressFormat = CompressFormat.JPEG + override val fileExtension: String = "jpg" + override val mimeType: String = "image/jpeg" + } + + data object WebpLossless : SaveFormat { + override val compressFormat: CompressFormat = CompressFormat.WEBP_LOSSLESS + override val fileExtension: String = "webp" + override val mimeType: String = "image/webp" + } + + data object WebpLossy : SaveFormat { + override val compressFormat: CompressFormat = CompressFormat.WEBP_LOSSY + override val fileExtension: String = "webp" + override val mimeType: String = "image/webp" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropImage.kt new file mode 100644 index 0000000000..d4d7108f89 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropImage.kt @@ -0,0 +1,10 @@ +package com.dot.gallery.feature_node.data.model.editor.crop + +import android.graphics.Bitmap +import androidx.compose.runtime.Immutable + +@Immutable +internal data class CropImage( + val sourceBitmap: Bitmap, + val previewBitmap: Bitmap, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropPixelRect.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropPixelRect.kt new file mode 100644 index 0000000000..1c1c6958bb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/CropPixelRect.kt @@ -0,0 +1,66 @@ +package com.dot.gallery.feature_node.data.model.editor.crop + +import android.graphics.Rect +import androidx.compose.runtime.Immutable + +@Immutable +internal data class CropPixelRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) { + val width: Int + get() { + return right - left + } + + val height: Int + get() { + return bottom - top + } + + fun toAndroidRect(): Rect { + return Rect( + left, + top, + right, + bottom, + ) + } + + companion object { + fun fromNormalizedRect( + normalizedRect: NormalizedCropRect, + bitmapWidth: Int, + bitmapHeight: Int, + ): CropPixelRect { + val left = (normalizedRect.left * bitmapWidth.toFloat()).toInt().coerceIn( + minimumValue = 0, + maximumValue = bitmapWidth - 1, + ) + + val top = (normalizedRect.top * bitmapHeight.toFloat()).toInt().coerceIn( + minimumValue = 0, + maximumValue = bitmapHeight - 1, + ) + + val right = (normalizedRect.right * bitmapWidth.toFloat()).toInt().coerceIn( + minimumValue = left + 1, + maximumValue = bitmapWidth, + ) + + val bottom = (normalizedRect.bottom * bitmapHeight.toFloat()).toInt().coerceIn( + minimumValue = top + 1, + maximumValue = bitmapHeight, + ) + + return CropPixelRect( + left = left, + top = top, + right = right, + bottom = bottom, + ) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequest.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequest.kt new file mode 100644 index 0000000000..9f4a036eb4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequest.kt @@ -0,0 +1,63 @@ +package com.dot.gallery.feature_node.data.model.editor.crop + +import android.net.Uri +import androidx.compose.runtime.Immutable +import com.dot.gallery.feature_node.data.model.editor.SaveFormat + +@Immutable +internal data class ExternalCropRequest( + val sourceUri: Uri, + val outputUri: Uri?, + val outputX: Int, + val outputY: Int, + val scale: Boolean, + val scaleUpIfNeeded: Boolean, + val aspectX: Int, + val aspectY: Int, + val returnData: Boolean, + val outputFormat: String?, +) { + val saveFormat: SaveFormat + get() { + return when (normalizedOutputFormatToken()) { + FORMAT_PNG, FORMAT_X_PNG, FORMAT_GIF -> SaveFormat.Png + FORMAT_WEBP, FORMAT_WEBP_LOSSY -> SaveFormat.WebpLossy + FORMAT_WEBP_LOSSLESS -> SaveFormat.WebpLossless + FORMAT_JPEG, FORMAT_JPG -> SaveFormat.Jpeg + else -> SaveFormat.Jpeg + } + } + + val aspectRatio: Float? + get() { + return when { + aspectX > 0 && aspectY > 0 -> aspectX.toFloat() / aspectY.toFloat() + outputX > 0 && outputY > 0 -> outputX.toFloat() / outputY.toFloat() + else -> null + } + } + + private fun normalizedOutputFormatToken(): String? { + return outputFormat + ?.trim() + ?.substringBefore(delimiter = MIME_PARAMETER_DELIMITER) + ?.trim() + ?.substringAfterLast(delimiter = MIME_TYPE_DELIMITER) + ?.substringAfterLast(delimiter = FILE_EXTENSION_DELIMITER) + ?.lowercase() + } + + private companion object { + private const val FILE_EXTENSION_DELIMITER = '.' + private const val FORMAT_GIF = "gif" + private const val FORMAT_JPEG = "jpeg" + private const val FORMAT_JPG = "jpg" + private const val FORMAT_PNG = "png" + private const val FORMAT_WEBP = "webp" + private const val FORMAT_WEBP_LOSSLESS = "webp_lossless" + private const val FORMAT_WEBP_LOSSY = "webp_lossy" + private const val FORMAT_X_PNG = "x-png" + private const val MIME_PARAMETER_DELIMITER = ';' + private const val MIME_TYPE_DELIMITER = '/' + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/NormalizedCropRect.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/NormalizedCropRect.kt new file mode 100644 index 0000000000..f8dcfc52b3 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/NormalizedCropRect.kt @@ -0,0 +1,95 @@ +package com.dot.gallery.feature_node.data.model.editor.crop + +import android.graphics.RectF +import androidx.compose.runtime.Immutable +import kotlin.math.abs + +@Immutable +internal data class NormalizedCropRect( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) { + + constructor(rect: RectF) : this( + left = rect.left, + top = rect.top, + right = rect.right, + bottom = rect.bottom, + ) + + fun toAndroidRectF(): RectF { + return RectF( + left, + top, + right, + bottom, + ) + } + + companion object { + fun full(): NormalizedCropRect { + return NormalizedCropRect( + left = 0f, + top = 0f, + right = 1f, + bottom = 1f, + ) + } + + fun centeredForAspectRatio( + sourceWidth: Int, + sourceHeight: Int, + aspectRatio: Float?, + ): NormalizedCropRect { + if (sourceWidth <= 0 || sourceHeight <= 0 || aspectRatio == null || aspectRatio <= 0f) { + return full() + } + + val sourceAspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat() + return when { + abs(sourceAspectRatio - aspectRatio) < ASPECT_RATIO_EPSILON -> full() + sourceAspectRatio > aspectRatio -> { + centeredWidthCrop(width = aspectRatio / sourceAspectRatio) + } + + else -> { + centeredHeightCrop(height = sourceAspectRatio / aspectRatio) + } + } + } + + private fun centeredWidthCrop(width: Float): NormalizedCropRect { + val normalizedWidth = width.coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) + val left = (1f - normalizedWidth) / 2f + + return NormalizedCropRect( + left = left, + top = 0f, + right = left + normalizedWidth, + bottom = 1f, + ) + } + + private fun centeredHeightCrop(height: Float): NormalizedCropRect { + val normalizedHeight = height.coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) + val top = (1f - normalizedHeight) / 2f + + return NormalizedCropRect( + left = 0f, + top = top, + right = 1f, + bottom = top + normalizedHeight, + ) + } + + private const val ASPECT_RATIO_EPSILON = 0.0001f + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/securereview/AuthorizedSecureReviewRequest.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/securereview/AuthorizedSecureReviewRequest.kt new file mode 100644 index 0000000000..bbc6a18d1a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/model/securereview/AuthorizedSecureReviewRequest.kt @@ -0,0 +1,14 @@ +package com.dot.gallery.feature_node.data.model.securereview + +import android.net.Uri +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class AuthorizedSecureReviewRequest( + val uris: ImmutableList, +) { + init { + require(uris.isNotEmpty()) { "Secure review requires a primary URI" } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepository.kt new file mode 100644 index 0000000000..cfcc66060e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepository.kt @@ -0,0 +1,193 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.room.withTransaction +import com.dot.gallery.core.dataStore +import com.dot.gallery.feature_node.data.data_source.InternalDatabase +import com.dot.gallery.feature_node.data.data_source.forEachIdChunk +import com.dot.gallery.feature_node.data.model.AiMediaAnalysisPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +internal interface AiMediaAnalysisRepository { + + val preferences: Flow + + suspend fun getPreferences(): AiMediaAnalysisPreferences + + suspend fun needsMigration(): Boolean + + suspend fun completeMigration() + + suspend fun setAnalysisEnabled(enabled: Boolean) + + suspend fun setCategoryClassificationEnabled(enabled: Boolean) + + suspend fun completeAnalysisCleanup() + + suspend fun completeCategoryCleanup() + + suspend fun invalidateGeneratedData(mediaIds: Set) + + suspend fun removeMediaData(mediaIds: Set) + + suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List + + suspend fun clearAllGeneratedData() + + suspend fun clearCategoryGeneratedData() +} + +internal class AiMediaAnalysisRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context, + private val database: InternalDatabase, +) : AiMediaAnalysisRepository { + + override val preferences: Flow = context.dataStore.data.map { values -> + AiMediaAnalysisPreferences( + analysisEnabled = values[AI_MEDIA_ANALYSIS_ENABLED] ?: false, + categoryClassificationEnabled = resolveCategoryClassificationEnabled( + preferences = values, + ), + analysisCleanupPending = values[AI_ANALYSIS_CLEANUP_PENDING] ?: false, + categoryCleanupPending = values[AI_CATEGORY_CLEANUP_PENDING] ?: false, + ) + } + + override suspend fun getPreferences(): AiMediaAnalysisPreferences { + return preferences.first() + } + + override suspend fun needsMigration(): Boolean { + val migrationVersion = context.dataStore.data + .first()[AI_MEDIA_ANALYSIS_MIGRATION_VERSION] ?: 0 + return migrationVersion < CURRENT_MIGRATION_VERSION + } + + override suspend fun completeMigration() { + context.dataStore.edit { values -> + val categoriesEnabled = resolveCategoryClassificationEnabled(preferences = values) + values[AI_MEDIA_ANALYSIS_ENABLED] = values[AI_MEDIA_ANALYSIS_ENABLED] ?: false + values[AI_CATEGORY_CLASSIFICATION_ENABLED] = categoriesEnabled + values.remove(LEGACY_NO_CLASSIFICATION) + values[AI_MEDIA_ANALYSIS_MIGRATION_VERSION] = CURRENT_MIGRATION_VERSION + } + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + context.dataStore.edit { values -> + values[AI_MEDIA_ANALYSIS_ENABLED] = enabled + when { + enabled -> { + values[AI_ANALYSIS_CLEANUP_PENDING] = false + val categoriesEnabled = resolveCategoryClassificationEnabled( + preferences = values, + ) + + if (categoriesEnabled) { + values[AI_CATEGORY_CLEANUP_PENDING] = false + } + } + + else -> { + values[AI_ANALYSIS_CLEANUP_PENDING] = true + values[AI_CATEGORY_CLEANUP_PENDING] = true + } + } + } + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + context.dataStore.edit { values -> + values[AI_CATEGORY_CLASSIFICATION_ENABLED] = enabled + values[AI_CATEGORY_CLEANUP_PENDING] = !enabled + } + } + + override suspend fun completeAnalysisCleanup() { + context.dataStore.edit { values -> + values[AI_ANALYSIS_CLEANUP_PENDING] = false + val categoriesEnabled = resolveCategoryClassificationEnabled(preferences = values) + if (categoriesEnabled) { + values[AI_CATEGORY_CLEANUP_PENDING] = false + } + } + } + + override suspend fun completeCategoryCleanup() { + context.dataStore.edit { values -> + values[AI_CATEGORY_CLEANUP_PENDING] = false + } + } + + override suspend fun invalidateGeneratedData(mediaIds: Set) { + database.withTransaction { + forEachIdChunk(ids = mediaIds) { mediaIdChunk -> + database.getImageEmbeddingDao().deleteByIds(ids = mediaIdChunk) + database.getCategoryDao().removeGeneratedMediaFromAllCategories( + mediaIds = mediaIdChunk, + ) + } + } + } + + override suspend fun removeMediaData(mediaIds: Set) { + database.withTransaction { + forEachIdChunk(ids = mediaIds) { mediaIdChunk -> + database.getImageEmbeddingDao().deleteByIds(ids = mediaIdChunk) + database.getCategoryDao().removeMediaFromAllCategories(mediaIds = mediaIdChunk) + } + } + } + + override suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List { + return database.getCategoryDao().getClassifiedMediaIdPage( + afterId = afterId, + limit = limit, + ) + } + + override suspend fun clearAllGeneratedData() { + database.withTransaction { + database.getImageEmbeddingDao().deleteAll() + database.getCategoryDao().deleteAllGeneratedMediaCategories() + database.getCategoryDao().clearCategoryEmbeddings() + database.getCategoryDao().clearGeneratedMediaCategoryStagingState() + } + } + + override suspend fun clearCategoryGeneratedData() { + database.withTransaction { + database.getCategoryDao().deleteAllGeneratedMediaCategories() + database.getCategoryDao().clearCategoryEmbeddings() + database.getCategoryDao().clearGeneratedMediaCategoryStagingState() + } + } + + private fun resolveCategoryClassificationEnabled(preferences: Preferences): Boolean { + val legacyClassificationDisabled = preferences[LEGACY_NO_CLASSIFICATION] ?: false + return preferences[AI_CATEGORY_CLASSIFICATION_ENABLED] ?: !legacyClassificationDisabled + } + + companion object { + private const val CURRENT_MIGRATION_VERSION = 1 + + private val AI_ANALYSIS_CLEANUP_PENDING = + booleanPreferencesKey("ai_analysis_cleanup_pending") + private val AI_CATEGORY_CLASSIFICATION_ENABLED = + booleanPreferencesKey("ai_category_classification_enabled") + private val AI_CATEGORY_CLEANUP_PENDING = + booleanPreferencesKey("ai_category_cleanup_pending") + private val AI_MEDIA_ANALYSIS_ENABLED = booleanPreferencesKey("ai_media_analysis_enabled") + private val AI_MEDIA_ANALYSIS_MIGRATION_VERSION = + intPreferencesKey("ai_media_analysis_migration_version") + private val LEGACY_NO_CLASSIFICATION = booleanPreferencesKey("no_classification") + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepository.kt new file mode 100644 index 0000000000..a2bf033ffe --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepository.kt @@ -0,0 +1,660 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Intent +import android.content.IntentSender +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.graphics.Rect +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.util.Log +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import com.dot.gallery.feature_node.data.externalcrop.CropSize +import com.dot.gallery.feature_node.data.externalcrop.buildCropResultIntent +import com.dot.gallery.feature_node.data.externalcrop.resolveCropOutputSize +import com.dot.gallery.feature_node.data.externalcrop.resolveCropSourceDecodeSize +import com.dot.gallery.feature_node.data.externalcrop.resolveIntentBitmapSize +import com.dot.gallery.feature_node.data.model.editor.SaveFormat +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.CropPixelRect +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect +import com.dot.gallery.injection.qualifier.DefaultDispatcher +import com.dot.gallery.injection.qualifier.IoDispatcher +import java.io.IOException +import java.io.OutputStream +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +internal interface ExternalCropRepository { + + suspend fun loadImage(uri: Uri): CropImage? + + suspend fun saveCropResult( + request: ExternalCropRequest, + image: CropImage, + normalizedRect: NormalizedCropRect, + ): ExternalCropSaveResult +} + +internal sealed interface ExternalCropSaveResult { + + data class Saved(val resultIntent: Intent) : ExternalCropSaveResult + + /** + * The caller-supplied output uri is not writable by us. Scoped storage only lets an app write + * media it owns, and a caller that passes the output as the plain + * [android.provider.MediaStore.EXTRA_OUTPUT] extra alone delegates no uri grant along with it. + * The user resolves it by approving [intentSender], which also makes the overwrite explicit to + * them. + * + * This recovery path is MediaStore-specific: the request is built by + * [android.provider.MediaStore.createWriteRequest], which cannot describe a foreign authority. A + * non-MediaStore output reaches us only when the caller did delegate a write grant, so it needs + * no recovery; if such a write fails anyway, the throw is caught and degrades to [Failed]. + */ + data class OutputPermissionRequired(val intentSender: IntentSender) : ExternalCropSaveResult + + data object Failed : ExternalCropSaveResult +} + +internal class ExternalCropRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ExternalCropRepository { + + override suspend fun loadImage(uri: Uri): CropImage? { + return try { + val sourceBitmap = withContext(ioDispatcher) { + decodeBoundedCropBitmap( + uri = uri, + ) + } + val previewBitmap = withContext(defaultDispatcher) { + toCropPreviewBitmap(bitmap = sourceBitmap) + } + + CropImage( + sourceBitmap = sourceBitmap, + previewBitmap = previewBitmap, + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: IOException) { + logCropLoadFailure(uri = uri, throwable = exception) + null + } catch (exception: RuntimeException) { + logCropLoadFailure(uri = uri, throwable = exception) + null + } catch (error: OutOfMemoryError) { + logCropLoadFailure(uri = uri, throwable = error) + null + } + } + + override suspend fun saveCropResult( + request: ExternalCropRequest, + image: CropImage, + normalizedRect: NormalizedCropRect, + ): ExternalCropSaveResult { + val saveFormat = request.saveFormat + val cropResult = withContext(defaultDispatcher) { + createCropBitmapResult( + request = request, + image = image, + normalizedRect = normalizedRect, + ) + } + val outcome = writeRequestedOutput( + request = request, + bitmap = cropResult.bitmap, + saveFormat = saveFormat, + ) + val outputUri = when (outcome) { + is OutputWriteOutcome.Written -> outcome.uri + is OutputWriteOutcome.PermissionDenied -> { + return createOutputPermissionRequest(uri = outcome.uri) + } + + OutputWriteOutcome.Failed -> null + } + val requiresOutput = request.outputUri != null || !request.returnData + if (outputUri == null && requiresOutput) { + return ExternalCropSaveResult.Failed + } + + return ExternalCropSaveResult.Saved( + resultIntent = buildCropResultIntent( + croppedRect = cropResult.cropRect, + outputUri = outputUri, + returnDataBitmap = cropResult.returnDataBitmap, + ), + ) + } + + private fun createOutputPermissionRequest(uri: Uri): ExternalCropSaveResult { + val intentSender = try { + MediaStore.createWriteRequest(contentResolver, listOf(uri)).intentSender + } catch (exception: Exception) { + Log.w(TAG, "Failed to build write request for $uri", exception) + null + } + + return when (intentSender) { + null -> ExternalCropSaveResult.Failed + else -> ExternalCropSaveResult.OutputPermissionRequired(intentSender = intentSender) + } + } + + private suspend fun writeRequestedOutput( + request: ExternalCropRequest, + bitmap: Bitmap, + saveFormat: SaveFormat, + ): OutputWriteOutcome { + val outputUri = request.outputUri + return withContext(ioDispatcher) { + when { + outputUri != null -> { + writeCropOutput( + uri = outputUri, + bitmap = bitmap, + saveFormat = saveFormat, + ) + } + + request.returnData -> OutputWriteOutcome.Written(uri = null) + + else -> { + OutputWriteOutcome.Written( + uri = writeFallbackOutput( + sourceUri = request.sourceUri, + bitmap = bitmap, + saveFormat = saveFormat, + ), + ) + } + } + } + } + + private suspend fun writeFallbackOutput( + sourceUri: Uri, + bitmap: Bitmap, + saveFormat: SaveFormat, + ): Uri? { + return insertCropOutput( + bitmap = bitmap, + saveFormat = saveFormat, + displayName = croppedDisplayName( + sourceDisplayName = queryDisplayName(uri = sourceUri), + saveFormat = saveFormat, + ), + ) + } + + private fun queryDisplayName(uri: Uri): String? { + val cursor = try { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + ) + } catch (_: Exception) { + null + } + return useDisplayName(cursor = cursor) ?: uri.lastPathSegment + } + + private fun logCropLoadFailure(uri: Uri, throwable: Throwable) { + Log.w(TAG, "Failed to load crop source $uri", throwable) + } + + private fun decodeBoundedCropBitmap(uri: Uri): Bitmap { + val sourceSize = readCropImageSize( + uri = uri, + ) + val targetSize = resolveCropSourceDecodeSize( + sourceWidth = sourceSize.width, + sourceHeight = sourceSize.height, + ) + + return decodeCropBitmap( + uri = uri, + targetSize = targetSize, + ) + } + + private fun readCropImageSize(uri: Uri): CropSize { + var sourceSize: CropSize? = null + try { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource(contentResolver, uri), + ) { _, info, _ -> + val imageSize = info.size + sourceSize = CropSize( + width = imageSize.width, + height = imageSize.height, + ) + throw ImageHeaderDecodedException() + } + } catch (_: ImageHeaderDecodedException) { + return sourceSize ?: throw IOException("Image header did not include dimensions") + } + + return sourceSize ?: throw IOException("Image header did not include dimensions") + } + + private fun decodeCropBitmap( + uri: Uri, + targetSize: CropSize, + ): Bitmap { + return ImageDecoder.decodeBitmap( + ImageDecoder.createSource(contentResolver, uri), + ) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.setTargetSize(targetSize.width, targetSize.height) + } + } + + private fun cropToRect(bitmap: Bitmap, cropRect: CropPixelRect): Bitmap { + return Bitmap.createBitmap( + bitmap, + cropRect.left, + cropRect.top, + cropRect.width, + cropRect.height, + ) + } + + private fun createCropBitmapResult( + request: ExternalCropRequest, + image: CropImage, + normalizedRect: NormalizedCropRect, + ): CropBitmapResult { + val cropRect = CropPixelRect.fromNormalizedRect( + normalizedRect = normalizedRect, + bitmapWidth = image.sourceBitmap.width, + bitmapHeight = image.sourceBitmap.height, + ) + val croppedSourceBitmap = cropToRect( + bitmap = image.sourceBitmap, + cropRect = cropRect, + ) + val croppedBitmap = scaleForCropRequest( + bitmap = croppedSourceBitmap, + request = request, + ) + + val returnDataBitmap = when { + request.returnData -> downsampleForIntentResult(bitmap = croppedBitmap) + else -> null + } + + return CropBitmapResult( + cropRect = cropRect, + bitmap = croppedBitmap, + returnDataBitmap = returnDataBitmap, + ) + } + + private fun scaleForCropRequest(bitmap: Bitmap, request: ExternalCropRequest): Bitmap { + val targetSize = resolveCropOutputSize( + cropWidth = bitmap.width, + cropHeight = bitmap.height, + outputX = request.outputX, + outputY = request.outputY, + scale = request.scale, + scaleUpIfNeeded = request.scaleUpIfNeeded, + ) + + val isSameSize = targetSize.width == bitmap.width && targetSize.height == bitmap.height + + return when { + isSameSize -> bitmap + request.scale -> { + bitmap.scale( + width = targetSize.width, + height = targetSize.height, + ) + } + + else -> { + centerBitmapOnOutput( + bitmap = bitmap, + targetSize = targetSize, + ) + } + } + } + + private fun centerBitmapOnOutput(bitmap: Bitmap, targetSize: CropSize): Bitmap { + val outputBitmap = createBitmap(targetSize.width, targetSize.height) + + val sourceRect = centeredSourceRect( + bitmap = bitmap, + targetSize = targetSize, + ) + + val destinationRect = centeredDestinationRect( + sourceRect = sourceRect, + targetSize = targetSize, + ) + + copyBitmapPixels( + sourceBitmap = bitmap, + outputBitmap = outputBitmap, + sourceRect = sourceRect, + destinationRect = destinationRect, + ) + return outputBitmap + } + + private fun copyBitmapPixels( + sourceBitmap: Bitmap, + outputBitmap: Bitmap, + sourceRect: Rect, + destinationRect: Rect, + ) { + val rowPixels = IntArray(sourceRect.width()) + for (rowIndex in 0 until sourceRect.height()) { + sourceBitmap.getPixels( + rowPixels, + 0, + sourceRect.width(), + sourceRect.left, + sourceRect.top + rowIndex, + sourceRect.width(), + 1, + ) + outputBitmap.setPixels( + rowPixels, + 0, + sourceRect.width(), + destinationRect.left, + destinationRect.top + rowIndex, + sourceRect.width(), + 1, + ) + } + } + + private fun centeredSourceRect(bitmap: Bitmap, targetSize: CropSize): Rect { + val width = minOf(bitmap.width, targetSize.width) + val height = minOf(bitmap.height, targetSize.height) + val left = (bitmap.width - width) / 2 + val top = (bitmap.height - height) / 2 + + return Rect( + left, + top, + left + width, + top + height, + ) + } + + private fun centeredDestinationRect(sourceRect: Rect, targetSize: CropSize): Rect { + val left = (targetSize.width - sourceRect.width()) / 2 + val top = (targetSize.height - sourceRect.height()) / 2 + + return Rect( + left, + top, + left + sourceRect.width(), + top + sourceRect.height(), + ) + } + + private fun downsampleForIntentResult(bitmap: Bitmap): Bitmap { + val targetSize = resolveIntentBitmapSize( + width = bitmap.width, + height = bitmap.height, + ) + + val isSameSize = targetSize.width == bitmap.width && targetSize.height == bitmap.height + + return when { + isSameSize -> bitmap + else -> { + bitmap.scale( + width = targetSize.width, + height = targetSize.height, + ) + } + } + } + + private fun toCropPreviewBitmap(bitmap: Bitmap): Bitmap { + val longestSide = maxOf(bitmap.width, bitmap.height) + if (longestSide <= PREVIEW_MAX_SIZE) { + return bitmap + } + + val scale = PREVIEW_MAX_SIZE.toFloat() / longestSide.toFloat() + + return bitmap.scale( + width = (bitmap.width.toFloat() * scale).toInt().coerceAtLeast(minimumValue = 1), + height = (bitmap.height.toFloat() * scale).toInt().coerceAtLeast(minimumValue = 1), + ) + } + + private fun writeCropOutput( + uri: Uri, + bitmap: Bitmap, + saveFormat: SaveFormat, + ): OutputWriteOutcome { + return try { + openWritableOutputStream(uri = uri) + ?.use { output -> + compressOrThrow( + bitmap = bitmap, + saveFormat = saveFormat, + output = output, + ) + } + ?: return OutputWriteOutcome.Failed + + OutputWriteOutcome.Written(uri = uri) + } catch (exception: SecurityException) { + // MediaProvider rejects writes to media we do not own; the user can still grant it. + Log.w(TAG, "No write access to crop output $uri", exception) + OutputWriteOutcome.PermissionDenied(uri = uri) + } catch (exception: Exception) { + Log.w(TAG, "Failed to write crop output to $uri", exception) + OutputWriteOutcome.Failed + } + } + + private suspend fun insertCropOutput( + bitmap: Bitmap, + saveFormat: SaveFormat, + displayName: String, + ): Uri? { + val uri = contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + pendingCropOutputValues( + displayName = displayName, + saveFormat = saveFormat, + ), + ) ?: return null + + return try { + val wroteOutput = writeInsertedCropOutput( + uri = uri, + bitmap = bitmap, + saveFormat = saveFormat, + ) + + if (!wroteOutput) { + deleteInsertedCropOutput( + uri = uri, + ) + return null + } + + currentCoroutineContext().ensureActive() + + val publishedOutput = publishInsertedCropOutput(uri = uri) + + if (!publishedOutput) { + deleteInsertedCropOutput( + uri = uri, + ) + return null + } + + uri + } catch (exception: CancellationException) { + deleteInsertedCropOutput( + uri = uri, + ) + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to insert crop output", exception) + deleteInsertedCropOutput( + uri = uri, + ) + null + } + } + + private fun pendingCropOutputValues( + displayName: String, + saveFormat: SaveFormat, + ): ContentValues { + return ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, saveFormat.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Edited") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + + private fun writeInsertedCropOutput( + uri: Uri, + bitmap: Bitmap, + saveFormat: SaveFormat, + ): Boolean { + return openWritableOutputStream(uri = uri)?.use { output -> + compressOrThrow( + bitmap = bitmap, + saveFormat = saveFormat, + output = output, + ) + } != null + } + + private fun publishInsertedCropOutput(uri: Uri): Boolean { + val publishedRows = contentResolver.update( + uri, + publishedCropOutputValues(), + null, + null, + ) + + return publishedRows > 0 + } + + private fun publishedCropOutputValues(): ContentValues { + return ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + put( + MediaStore.MediaColumns.DATE_MODIFIED, + System.currentTimeMillis() / 1000, + ) + } + } + + private fun deleteInsertedCropOutput(uri: Uri) { + contentResolver.delete(uri, null, null) + } + + private fun openWritableOutputStream(uri: Uri): OutputStream? { + val truncatingStream = try { + contentResolver.openOutputStream(uri, "wt") + } catch (_: Exception) { + null + } + + return truncatingStream ?: contentResolver.openOutputStream(uri) + } + + private fun croppedDisplayName( + sourceDisplayName: String?, + saveFormat: SaveFormat, + ): String { + val baseName = sourceDisplayName + ?.substringBeforeLast(delimiter = '.', missingDelimiterValue = sourceDisplayName) + ?.takeIf { it.isNotBlank() } + ?: CROPPED_IMAGE_FALLBACK_NAME + + return "$baseName-cropped.${saveFormat.fileExtension}" + } + + private fun compressOrThrow( + bitmap: Bitmap, + saveFormat: SaveFormat, + output: OutputStream, + ) { + if (!bitmap.compress(saveFormat.compressFormat, 95, output)) { + throw IOException("Compression failed") + } + } + + private fun useDisplayName(cursor: Cursor?): String? { + return cursor?.use { currentCursor -> + currentCursor + .takeIf { it.moveToFirst() } + ?.let { currentCursor -> + val displayNameIndex = currentCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + when { + displayNameIndex >= 0 -> currentCursor.getString(displayNameIndex) + else -> null + } + } + } + } + + private data class CropBitmapResult( + val cropRect: CropPixelRect, + val bitmap: Bitmap, + val returnDataBitmap: Bitmap?, + ) + + /** Outcome of writing the crop bytes, before it is turned into an [ExternalCropSaveResult]. */ + private sealed interface OutputWriteOutcome { + + /** The uri is null when the caller only asked for `return-data`. */ + data class Written(val uri: Uri?) : OutputWriteOutcome + + data class PermissionDenied(val uri: Uri) : OutputWriteOutcome + + data object Failed : OutputWriteOutcome + } + + private class ImageHeaderDecodedException : RuntimeException() { + override fun fillInStackTrace(): Throwable { + return this + } + } + + private companion object { + private const val TAG = "ExternalCropRepository" + private const val PREVIEW_MAX_SIZE = 2048 + private const val CROPPED_IMAGE_FALLBACK_NAME = "cropped_image" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepository.kt new file mode 100644 index 0000000000..f46b5f9535 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepository.kt @@ -0,0 +1,214 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import com.dot.gallery.feature_node.data.util.resolveMediaStoreVolume +import com.dot.gallery.injection.qualifier.IoDispatcher +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +private const val TAG = "MediaCopyRepository" + +internal interface MediaCopyRepository { + + suspend fun getMediaSize(uri: Uri): Long? + + suspend fun copyMedia( + sourceUri: Uri, + destinationPath: String, + onBytesCopied: suspend (Int) -> Unit, + ): Uri? + + suspend fun notifyMediaChanged() +} + +internal class MediaCopyRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : MediaCopyRepository { + + override suspend fun getMediaSize(uri: Uri): Long? { + return withContext(ioDispatcher) { + try { + contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.length.takeIf { length -> length > 0L } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to read media size for $uri", exception) + null + } + } + } + + override suspend fun copyMedia( + sourceUri: Uri, + destinationPath: String, + onBytesCopied: suspend (Int) -> Unit, + ): Uri? { + return withContext(ioDispatcher) { + var destinationUri: Uri? = null + var destinationPublished = false + + try { + destinationUri = insertPendingMedia( + sourceUri = sourceUri, + destinationPath = destinationPath, + ) + + when (val insertedUri = destinationUri) { + null -> null + else -> { + val mediaCopied = copyMediaBytes( + sourceUri = sourceUri, + destinationUri = insertedUri, + onBytesCopied = onBytesCopied, + ) + destinationPublished = mediaCopied && publishMedia(uri = insertedUri) + insertedUri.takeIf { destinationPublished } + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to copy media from $sourceUri", exception) + null + } finally { + if (!destinationPublished) { + destinationUri?.let { uri -> + deleteUnpublishedMedia(uri = uri) + } + } + } + } + } + + override suspend fun notifyMediaChanged() { + withContext(ioDispatcher) { + try { + contentResolver.notifyChange( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + null, + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to notify MediaStore after copying media", exception) + } + } + } + + private fun insertPendingMedia(sourceUri: Uri, destinationPath: String): Uri? { + val mimeType = contentResolver.getType(sourceUri) + val (volumeName, relativePath) = resolveMediaStoreVolume(destinationPath) + val destinationCollection = mimeType?.let { type -> + getDestinationCollection( + volumeName = volumeName, + mimeType = type, + ) + } + + return when { + mimeType == null || destinationCollection == null -> null + else -> { + contentResolver.insert( + destinationCollection, + pendingMediaValues( + sourceUri = sourceUri, + relativePath = relativePath, + mimeType = mimeType, + ), + ) + } + } + } + + private fun getDestinationCollection(volumeName: String, mimeType: String): Uri? { + return when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.getContentUri(volumeName) + mimeType.startsWith("video/") -> MediaStore.Video.Media.getContentUri(volumeName) + else -> null + } + } + + private fun pendingMediaValues( + sourceUri: Uri, + relativePath: String, + mimeType: String, + ): ContentValues { + return ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, sourceUri.lastPathSegment) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + + private suspend fun copyMediaBytes( + sourceUri: Uri, + destinationUri: Uri, + onBytesCopied: suspend (Int) -> Unit, + ): Boolean { + return contentResolver.openInputStream(sourceUri)?.use { input -> + contentResolver.openOutputStream(destinationUri)?.use { output -> + copyBytes( + input = input, + output = output, + onBytesCopied = onBytesCopied, + ) + true + } + } == true + } + + private suspend fun copyBytes( + input: InputStream, + output: OutputStream, + onBytesCopied: suspend (Int) -> Unit, + ) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) { + break + } + output.write(buffer, 0, bytesRead) + onBytesCopied(bytesRead) + } + output.flush() + } + + private fun publishMedia(uri: Uri): Boolean { + val publishedRows = contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + put( + MediaStore.MediaColumns.DATE_MODIFIED, + System.currentTimeMillis() / 1000, + ) + }, + null, + null, + ) + + return publishedRows > 0 + } + + private fun deleteUnpublishedMedia(uri: Uri) { + try { + contentResolver.delete(uri, null, null) + } catch (exception: Exception) { + Log.w(TAG, "Failed to delete unpublished media at $uri", exception) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepository.kt similarity index 54% rename from app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepository.kt index da37d243f2..000b561979 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepository.kt @@ -9,115 +9,382 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.location.Geocoder import android.net.Uri -import android.os.Environment -import android.os.Build import android.provider.MediaStore import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.core.app.ActivityOptionsCompat -import com.dot.gallery.core.util.SdkCompat import androidx.datastore.preferences.core.Preferences import androidx.work.WorkManager import com.dot.gallery.core.Resource +import com.dot.gallery.core.Settings import com.dot.gallery.core.dataStore +import com.dot.gallery.core.sandbox.IsolatedMetadataParser import com.dot.gallery.core.util.MediaStoreBuckets +import com.dot.gallery.core.util.SdkCompat import com.dot.gallery.core.util.ext.deleteGpsMetadata import com.dot.gallery.core.util.ext.deleteMetadata import com.dot.gallery.core.util.ext.mapAsResource -import com.dot.gallery.core.util.ext.overrideImage import com.dot.gallery.core.util.ext.renameMedia import com.dot.gallery.core.util.ext.saveImage -import com.dot.gallery.core.util.ext.saveRawImage -import com.dot.gallery.core.util.ext.saveVideo -import com.dot.gallery.core.util.ext.saveVideoStream -import com.dot.gallery.core.util.ext.saveRawStream import com.dot.gallery.core.util.ext.updateImageDescription import com.dot.gallery.core.util.ext.updateMedia import com.dot.gallery.core.util.ext.updateMediaExif -import com.dot.gallery.core.workers.copyMedia +import com.dot.gallery.core.workers.MediaCopyRequest +import com.dot.gallery.core.workers.MediaCopyScheduler import com.dot.gallery.core.workers.updateDatabase -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.data.data_source.KeychainHolder +import com.dot.gallery.feature_node.data.data_source.flatMapIdChunks import com.dot.gallery.feature_node.data.data_source.mediastore.queries.AlbumsFlow import com.dot.gallery.feature_node.data.data_source.mediastore.queries.MediaFlow import com.dot.gallery.feature_node.data.data_source.mediastore.queries.MediaUriFlow -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember -import com.dot.gallery.feature_node.domain.model.Collection -import com.dot.gallery.feature_node.domain.model.CollectionMedia -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.model.AlbumThumbnail -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.ClassifiedMedia -import com.dot.gallery.feature_node.domain.model.Media.EncryptedMedia -import com.dot.gallery.feature_node.domain.model.Media.UriMedia -import com.dot.gallery.feature_node.domain.model.MediaCategory -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.core.sandbox.IsolatedMetadataParser -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.retrieveExtraMediaMetadata -import com.dot.gallery.feature_node.domain.model.toMediaMetadata -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import com.dot.gallery.feature_node.domain.util.asUriMedia -import com.dot.gallery.feature_node.domain.util.compatibleBitmapFormat -import com.dot.gallery.feature_node.domain.util.compatibleMimeType -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isImage -import com.dot.gallery.feature_node.domain.util.isRawFile -import com.dot.gallery.feature_node.domain.util.isVideo -import com.dot.gallery.feature_node.domain.util.migrate -import com.dot.gallery.feature_node.domain.util.toEncryptedMedia2 +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.AlbumGroup +import com.dot.gallery.feature_node.data.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.AlbumThumbnail +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Collection +import com.dot.gallery.feature_node.data.model.CollectionMedia +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.ImageEmbeddingStamp +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.Media.ClassifiedMedia +import com.dot.gallery.feature_node.data.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.MediaCategory +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.model.retrieveExtraMediaMetadata +import com.dot.gallery.feature_node.data.model.toMediaMetadata +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.OrderType +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isVideo +import com.dot.gallery.feature_node.data.util.resolveMediaStoreVolume import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.BOTH import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.PHOTOS import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.VIDEOS -import com.dot.gallery.feature_node.presentation.util.printError -import com.dot.gallery.feature_node.presentation.util.printInfo import com.dot.gallery.feature_node.presentation.util.printWarning +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File -class MediaRepositoryImpl( +interface MediaRepository { + + suspend fun updateInternalDatabase() + + fun getMedia(): Flow>> + + fun getCompleteMedia(): Flow>> + + suspend fun getCompleteMediaPage(afterId: Long, limit: Int): List + + fun getMediaByType(allowedMedia: AllowedMedia): Flow>> + + fun getFavorites(mediaOrder: MediaOrder): Flow>> + + fun getTrashed(): Flow>> + + fun getAlbums(mediaOrder: MediaOrder): Flow>> + + fun getAlbum(albumId: Long): Flow> + + suspend fun insertPinnedAlbum(pinnedAlbum: PinnedAlbum) + + suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) + + fun getPinnedAlbums(): Flow> + + suspend fun insertLockedAlbum(lockedAlbum: LockedAlbum) + + suspend fun removeLockedAlbum(lockedAlbum: LockedAlbum) + + fun getLockedAlbums(): Flow> + + suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) + + suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) + + fun getBlacklistedAlbums(): Flow> + + suspend fun getBlacklistedAlbumsAsync(): List + + fun getMediaByAlbumId(albumId: Long): Flow>> + + fun getMediaByAlbumIdWithType( + albumId: Long, + allowedMedia: AllowedMedia + ): Flow>> + + fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> + + fun getMediaListByUris( + listOfUris: List, + reviewMode: Boolean, + onlyMatching: Boolean = false, + ): Flow>> + + suspend fun toggleFavorite( + result: ActivityResultLauncher, + mediaList: List, + favorite: Boolean + ) + + suspend fun trashMedia( + result: ActivityResultLauncher, + mediaList: List, + trash: Boolean + ) + + suspend fun copyMedia( + from: T, + path: String + ) + + suspend fun copyMedia(vararg sets: Pair) + + suspend fun deleteMedia( + result: ActivityResultLauncher, + mediaList: List + ) + + suspend fun renameMedia( + media: T, + newName: String + ): Boolean + + suspend fun moveMedia( + media: T, + newPath: String + ): Boolean + + suspend fun deleteMediaMetadata(media: T): Boolean + + suspend fun deleteMediaGPSMetadata(media: T): Boolean + + suspend fun updateMediaDescription( + media: T, + description: String + ): Boolean + + suspend fun saveImage( + bitmap: Bitmap, + format: Bitmap.CompressFormat, + mimeType: String, + relativePath: String, + displayName: String + ): Uri? + + fun getTimelineSettings(): Flow + + suspend fun updateTimelineSettings(settings: TimelineSettings) + + fun getSetting(key: Preferences.Key, defaultValue: Result): Flow + + // ============ Legacy Classification (to be deprecated) ============ + fun getClassifiedCategories(): Flow> + + fun getClassifiedMediaByCategory(category: String?): Flow> + + fun getClassifiedMediaByMostPopularCategory(): Flow> + + fun getCategoriesWithMedia(): Flow> + + fun getClassifiedMediaCount(): Flow + + fun getClassifiedMediaCountAtCategory(category: String): Flow + + fun getClassifiedMediaThumbnailByCategory(category: String): Flow + + suspend fun getCategoryForMediaId(mediaId: Long): String? + suspend fun changeCategory(mediaId: Long, newCategory: String) + + suspend fun deleteClassifications() + + // ============ New Category System ============ + + // Category CRUD + suspend fun createCategory(category: Category): Long + suspend fun updateCategory(category: Category) + suspend fun deleteCategory(categoryId: Long) + fun getCategory(categoryId: Long): Flow + suspend fun getCategoryAsync(categoryId: Long): Category? + fun getAllCategories(): Flow> + suspend fun getAllCategoriesAsync(): List + fun getCategoriesWithMediaCount(): Flow> + fun getCategoryCount(): Flow + fun getTopCategories(limit: Int = 10): Flow> + + // Category settings + suspend fun updateCategoryThreshold(categoryId: Long, threshold: Float) + suspend fun updateCategoryName(categoryId: Long, name: String) + suspend fun toggleCategoryPinned(categoryId: Long, isPinned: Boolean) + + // Media-Category associations + fun getMediaIdsInCategory(categoryId: Long): Flow> + suspend fun getMediaIdsInCategoryAsync(categoryId: Long): List + fun getCategoriesForMedia(mediaId: Long): Flow> + suspend fun addMediaToCategory(mediaId: Long, categoryId: Long, similarity: Float = 1f, isManual: Boolean = true) + suspend fun removeMediaFromCategory(mediaId: Long, categoryId: Long) + fun getMediaCountInCategory(categoryId: Long): Flow + fun getThumbnailMediaIdForCategory(categoryId: Long): Flow + + // Category initialization and management + suspend fun initializeDefaultCategories() + suspend fun resetCategoryData() + + fun getMetadata(): Flow> + + fun getMetadata(media: Media): Flow + + suspend fun updateAlbumThumbnail(albumId: Long, thumbnail: Uri) + + suspend fun deleteAlbumThumbnail(albumId: Long) + + fun getAlbumThumbnail(albumId: Long): Flow + + fun getAlbumThumbnails(): Flow> + + fun hasAlbumThumbnail(albumId: Long): Flow + + suspend fun collectMetadataFor(media: Media) + + suspend fun addImageEmbedding(imageEmbedding: ImageEmbedding) + + suspend fun getRecord(id: Long): ImageEmbedding? + + suspend fun getImageEmbeddingStampPage(afterId: Long, limit: Int): List + + suspend fun getImageEmbeddingPage(afterId: Long, limit: Int): List + + suspend fun getImageEmbeddingsByIds(ids: Set): List + + // ============ Album Groups ============ + + suspend fun insertAlbumGroup(group: AlbumGroup): Long + + suspend fun updateAlbumGroup(group: AlbumGroup) + + suspend fun deleteAlbumGroup(groupId: Long) + + fun getAllAlbumGroups(): Flow> + + fun getAlbumGroup(groupId: Long): Flow + + suspend fun getAlbumGroupAsync(groupId: Long): AlbumGroup? + + suspend fun addAlbumToGroup(member: AlbumGroupMember) + + suspend fun removeAlbumFromGroup(member: AlbumGroupMember) + + suspend fun removeAllAlbumsFromGroup(groupId: Long) + + fun getAlbumIdsInGroup(groupId: Long): Flow> + + fun getAllGroupMembers(): Flow> + + suspend fun getGroupIdForAlbum(albumId: Long): Long? + + // ============ Merged Subfolder Albums ============ + + suspend fun insertMergedSubfolderAlbum(mergedSubfolderAlbum: MergedSubfolderAlbum) + + suspend fun removeMergedSubfolderAlbum(mergedSubfolderAlbum: MergedSubfolderAlbum) + + fun getMergedSubfolderAlbums(): Flow> + + // ============ Collections ============ + + suspend fun insertCollection(collection: Collection): Long + + suspend fun updateCollection(collection: Collection) + + suspend fun deleteCollection(collectionId: Long) + + fun getCollection(collectionId: Long): Flow + + suspend fun getCollectionAsync(collectionId: Long): Collection? + + fun getAllCollections(): Flow> + + fun getCollectionsWithCount(): Flow> + + suspend fun updateCollectionLabel(collectionId: Long, label: String) + + suspend fun toggleCollectionPinned(collectionId: Long, isPinned: Boolean) + + suspend fun updateCollectionCover(collectionId: Long, mediaId: Long?) + + suspend fun addMediaToCollection(collectionId: Long, mediaId: Long) + + suspend fun addMediaListToCollection(collectionId: Long, mediaIds: List) + + suspend fun removeMediaFromCollection(collectionId: Long, mediaId: Long) + + fun getMediaIdsInCollection(collectionId: Long): Flow> + + suspend fun getMediaIdsInCollectionAsync(collectionId: Long): List + + fun getMediaCountInCollection(collectionId: Long): Flow + + fun getCollectionIdsForMedia(mediaId: Long): Flow> + + suspend fun cleanupOrphanedCollectionMedia(validMediaIds: List) + + suspend fun addAlbumsToCollection(collectionId: Long, albumIds: List) + + suspend fun removeAlbumFromCollection(collectionId: Long, albumId: Long) + + fun getAllAlbumIdsInCollections(): Flow> + + fun getAlbumIdsInCollection(collectionId: Long): Flow> + +} + +internal class MediaRepositoryImpl( private val context: Context, private val workManager: WorkManager, + private val mediaCopyScheduler: MediaCopyScheduler, private val database: InternalDatabase, - private val keychainHolder: KeychainHolder, private val geocoder: Geocoder?, private val isolatedParser: IsolatedMetadataParser ) : MediaRepository { private val contentResolver = context.contentResolver + /** + * On-demand metadata operations use per-file isolation in both hybrid and per-file modes. + */ + private suspend fun shouldUsePerFileIsolation(): Boolean { + val mode = Settings.Security.getMetadataIsolationMode(context) + .firstOrNull() ?: Settings.Security.DEFAULT_METADATA_ISOLATION_MODE + return mode != Settings.Security.METADATA_ISOLATION_SHARED + } + private var updateDatabaseMutex = Mutex() + override suspend fun updateInternalDatabase() { if (!updateDatabaseMutex.isLocked) { updateDatabaseMutex.withLock { - delay(5000) // Delay to ensure the database is not updated too frequently + delay(5000.milliseconds) // Delay to ensure the database is not updated too frequently workManager.updateDatabase() } } @@ -145,6 +412,16 @@ class MediaRepositoryImpl( Resource.Success(MediaOrder.Date(OrderType.Descending).sortMedia(it)) }.flowOn(Dispatchers.IO) + override suspend fun getCompleteMediaPage(afterId: Long, limit: Int): List { + return MediaFlow( + contentResolver = contentResolver, + buckedId = MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id, + skipBatching = true, + afterId = afterId, + limit = limit, + ).flowData().first() + } + override fun getMediaByType(allowedMedia: AllowedMedia): Flow>> = MediaFlow( contentResolver = contentResolver, @@ -250,12 +527,16 @@ class MediaRepositoryImpl( listOfUris: List, reviewMode: Boolean, onlyMatching: Boolean - ): Flow>> = - MediaUriFlow( + ): Flow>> { + return MediaUriFlow( contentResolver = contentResolver, uris = listOfUris, - onlyMatchingUris = onlyMatching - ).flowData().mapAsResource(errorOnEmpty = true, errorMessage = "Media could not be opened") + onlyMatchingUris = onlyMatching, + ).flowData().mapAsResource( + errorOnEmpty = true, + errorMessage = "Media could not be opened", + ) + } override suspend fun toggleFavorite( result: ActivityResultLauncher, @@ -332,14 +613,39 @@ class MediaRepositoryImpl( from: T, path: String ) { - workManager.copyMedia( - from = from as UriMedia, - path = path, + val requests = listOf( + MediaCopyRequest( + sourceUri = from.getUri(), + destinationPath = path, + ), + ) + + val batch = mediaCopyScheduler.prepareBatch(requests = requests) + + mediaCopyScheduler.enqueue( + batch = batch, + requests = requests, ) } override suspend fun copyMedia(vararg sets: Pair) { - workManager.copyMedia(*sets) + if (sets.isEmpty()) { + return + } + + val requests = sets.map { (media, path) -> + MediaCopyRequest( + sourceUri = media.getUri(), + destinationPath = path, + ) + } + + val batch = mediaCopyScheduler.prepareBatch(requests = requests) + + mediaCopyScheduler.enqueue( + batch = batch, + requests = requests, + ) } override suspend fun renameMedia( @@ -352,18 +658,103 @@ class MediaRepositoryImpl( override suspend fun moveMedia( media: T, - newPath: String - ): Boolean = context.updateMedia( - media = media, - contentValues = relativePath(newPath) - ) + newPath: String, + ): Boolean { + val (destVolume, destRelPath) = resolveMediaStoreVolume(newPath) + val sourceVolume = resolveMediaStoreVolume(path = media.path).first + + if (destVolume == sourceVolume) { + return context.updateMedia( + media = media, + contentValues = relativePath(destRelPath) + ) + } + + return crossVolumeMove( + media = media, + destVolume = destVolume, + destRelPath = destRelPath, + ) + } + + private suspend fun crossVolumeMove( + media: T, + destVolume: String, + destRelPath: String, + ): Boolean { + return withContext(Dispatchers.IO) { + val cr = context.contentResolver + var destinationUri: Uri? = null + var destinationPublished = false + try { + val srcUri = media.getUri() + val mediaType = cr.getType(srcUri) ?: return@withContext false + val isVideo = mediaType.startsWith("video") + + destinationUri = cr.insert( + if (isVideo) MediaStore.Video.Media.getContentUri(destVolume) + else MediaStore.Images.Media.getContentUri(destVolume), + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, media.label) + put(MediaStore.MediaColumns.MIME_TYPE, media.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, destRelPath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + }, + ) ?: return@withContext false + + val copied = cr.openInputStream(srcUri)?.use { input -> + cr.openOutputStream(destinationUri)?.use { output -> + input.copyTo(output) + true + } + } == true + + if (!copied) { + cr.delete(destinationUri, null, null) + return@withContext false + } + + val publishedRows = cr.update( + destinationUri, + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + put( + MediaStore.MediaColumns.DATE_MODIFIED, + System.currentTimeMillis() / 1000, + ) + }, + null, null + ) + if (publishedRows <= 0) { + cr.delete(destinationUri, null, null) + return@withContext false + } + destinationPublished = true + + cr.delete(srcUri, null, null) > 0 + } catch (exception: Exception) { + if (!destinationPublished) { + destinationUri?.let { uri -> + cr.delete(uri, null, null) + } + } + printWarning("Cross-volume move failed: ${exception.message}") + false + } + } + } override suspend fun deleteMediaGPSMetadata(media: T): Boolean = context.updateMediaExif( media = media, action = { deleteGpsMetadata() }, postAction = { - context.retrieveExtraMediaMetadata(isolatedParser, geocoder, it)?.let { metadata -> + context.retrieveExtraMediaMetadata( + isolatedParser = isolatedParser, + geocoder = geocoder, + media = it, + usePerFileIsolation = shouldUsePerFileIsolation(), + )?.let { metadata -> database.getMetadataDao().addMetadata(metadata) } } @@ -374,7 +765,12 @@ class MediaRepositoryImpl( media = media, action = { deleteMetadata() }, postAction = { - context.retrieveExtraMediaMetadata(isolatedParser, geocoder, it)?.let { metadata -> + context.retrieveExtraMediaMetadata( + isolatedParser = isolatedParser, + geocoder = geocoder, + media = it, + usePerFileIsolation = shouldUsePerFileIsolation(), + )?.let { metadata -> database.getMetadataDao().addMetadata(metadata) } } @@ -407,7 +803,12 @@ class MediaRepositoryImpl( media = media, action = { updateImageDescription(description) }, postAction = { - context.retrieveExtraMediaMetadata(isolatedParser, geocoder, it)?.let { metadata -> + context.retrieveExtraMediaMetadata( + isolatedParser = isolatedParser, + geocoder = geocoder, + media = it, + usePerFileIsolation = shouldUsePerFileIsolation(), + )?.let { metadata -> database.getMetadataDao().addMetadata(metadata) } } @@ -423,427 +824,6 @@ class MediaRepositoryImpl( displayName: String ) = contentResolver.saveImage(bitmap, format, mimeType, relativePath, displayName) - override suspend fun overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ) = contentResolver.overrideImage(uri, bitmap, format) - - override fun getVaults(): Flow>> = database - .getVaultDao() - .getVaults().map { vaults -> - with(keychainHolder) { - val newVaults = vaults.mapNotNull { vault -> - if (vaultFolder(vault).exists()) vault else { - printWarning("Vault ${vault.uuid} does not exist. It will be deleted from the database.") - database.getVaultDao().deleteVault(vault) - null - } - } - Resource.Success(newVaults) - } - } - - override suspend fun createVault( - vault: Vault, - transferable: Boolean, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) = withContext(Dispatchers.IO) { - keychainHolder.writeVaultInfo( - vault = vault, - transferable = transferable, - onSuccess = { - launch(Dispatchers.IO) { - database.getVaultDao().insertVault(vault) - onSuccess() - } - }, - onFailed = onFailed - ) - } - - override suspend fun deleteVault( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) = withContext(Dispatchers.IO) { - keychainHolder.deleteVault( - vault = vault, - onSuccess = { - launch(Dispatchers.IO) { - database.getVaultDao().deleteVault(vault) - onSuccess() - } - }, - onFailed = onFailed - ) - } - - override fun getEncryptedMedia(vault: Vault?): Flow>> = - database.getVaultDao().getMediaFromVault(vault?.uuid).map { mediaList -> - with(keychainHolder) { - val newMedia = mediaList.mapNotNull { media -> - try { - val encryptedFile = vault!!.mediaFile(media.id) - if (encryptedFile.exists()) { - media.asUriMedia(Uri.fromFile(encryptedFile)) - } else { - printWarning("Encrypted Media ${media.id} under ${vault.uuid} does not exist. It will be deleted from the database.") - database.getVaultDao().deleteMediaFromVault(media) - null - } - } catch (e: Throwable) { - e.printStackTrace() - null - } - }.sortedByDescending { it.timestamp } - Resource.Success(newMedia) - } - } - - override suspend fun addMedia(vault: Vault, media: T): Boolean = - withContext(Dispatchers.IO) { - with(keychainHolder) { - keychainHolder.checkVaultFolder(vault) - // Skip duplicate: if this media ID already exists in this vault, treat as success - if (database.getVaultDao().mediaExistsInVault(vault.uuid, media.id)) { - printInfo("Skipping duplicate: ${media.label} already in vault ${vault.name}") - return@withContext true - } - val output = vault.mediaFile(media.id).apply { if (exists()) delete() } - // Ensure vault uses portable format for streaming encryption - if (!isTransferable(vault)) { - writeVaultInfo(vault, transferable = true) - } - return@withContext try { - val inputStream = context.contentResolver.openInputStream(media.getUri()) - ?: return@withContext false - inputStream.use { input -> - encryptPortableStream(vault, input, output) - } - output.setLastModified(System.currentTimeMillis()) - database.getVaultDao().addMediaToVault(media.toEncryptedMedia2(vault.uuid)) - true - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to add file: ${media.label}") - output.delete() - false - } - } - } - - override suspend fun restoreMedia(vault: Vault, media: T): Boolean = - withContext(Dispatchers.IO) { - with(keychainHolder) { - checkVaultFolder(vault) - return@withContext try { - val encFile = vault.mediaFile(media.id) - val restored: Boolean - if (isPortableFile(encFile)) { - // Portable format: stream-decrypt directly to MediaStore - restored = if (media.isRawFile) { - contentResolver.saveRawStream( - writeBlock = { out -> decryptPortableStream(vault, encFile, out) }, - displayName = media.label, - mimeType = media.mimeType, - relativePath = Environment.DIRECTORY_PICTURES + "/Restored" - ) != null - } else if (media.isImage) { - // Images need bitmap decode/re-encode for format compatibility - contentResolver.saveRawStream( - writeBlock = { out -> decryptPortableStream(vault, encFile, out) }, - displayName = media.label, - mimeType = media.mimeType, - relativePath = Environment.DIRECTORY_PICTURES + "/Restored" - ) != null - } else { - contentResolver.saveVideoStream( - writeBlock = { out -> decryptPortableStream(vault, encFile, out) }, - displayName = media.label, - mimeType = media.compatibleMimeType(), - relativePath = Environment.DIRECTORY_MOVIES + "/Restored" - ) != null - } - } else { - // Legacy format: in-memory decryption (only for old small files) - val encryptedMedia = encFile.decryptKotlin() - restored = if (media.isRawFile) { - contentResolver.saveRawImage( - data = encryptedMedia.bytes, - displayName = media.label, - mimeType = media.mimeType, - relativePath = Environment.DIRECTORY_PICTURES + "/Restored" - ) != null - } else if (media.isImage) { - saveImage( - bitmap = BitmapFactory.decodeByteArray( - encryptedMedia.bytes, - 0, - encryptedMedia.bytes.size - ), - displayName = media.label, - mimeType = media.compatibleMimeType(), - format = media.compatibleBitmapFormat(), - relativePath = Environment.DIRECTORY_PICTURES + "/Restored" - ) != null - } else { - contentResolver.saveVideo( - data = encryptedMedia.bytes, - displayName = media.label, - mimeType = media.compatibleMimeType(), - relativePath = Environment.DIRECTORY_MOVIES + "/Restored" - ) != null - } - } - val deleted = if (restored) encFile.delete() else false - if (deleted) { - database.getVaultDao() - .deleteMediaFromVault(vault.uuid, media.id) - } - restored && deleted - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to restore file: ${media.label}") - false - } - } - } - - override suspend fun transferMedia( - sourceVault: Vault, - targetVault: Vault, - media: T, - copy: Boolean - ): Boolean = withContext(Dispatchers.IO) { - with(keychainHolder) { - checkVaultFolder(sourceVault) - checkVaultFolder(targetVault) - if (!isTransferable(targetVault)) { - writeVaultInfo(targetVault, transferable = true) - } - return@withContext try { - val sourceFile = sourceVault.mediaFile(media.id) - if (!sourceFile.exists()) { - printError("Transfer failed: source file does not exist for ${media.label} (id=${media.id})") - return@withContext false - } - val targetFile = targetVault.mediaFile(media.id).apply { if (exists()) delete() } - // Decrypt from source, re-encrypt into target - val buffer = java.io.ByteArrayOutputStream() - if (isPortableFile(sourceFile)) { - decryptPortableStream(sourceVault, sourceFile, buffer) - } else { - val legacy = sourceFile.decryptKotlin() - buffer.write(legacy.bytes) - } - val decryptedBytes = buffer.toByteArray() - if (decryptedBytes.isEmpty()) { - printError("Transfer failed: decrypted data is empty for ${media.label}") - return@withContext false - } - java.io.ByteArrayInputStream(decryptedBytes).use { input -> - encryptPortableStream(targetVault, input, targetFile) - } - targetFile.setLastModified(System.currentTimeMillis()) - database.getVaultDao().addMediaToVault(media.toEncryptedMedia2(targetVault.uuid)) - if (!copy) { - sourceFile.delete() - database.getVaultDao().deleteMediaFromVault(sourceVault.uuid, media.id) - } - printInfo("Transferred ${media.label} from ${sourceVault.name} to ${targetVault.name} (copy=$copy)") - true - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to transfer file: ${media.label}: ${e.message}") - false - } - } - } - - override suspend fun deleteEncryptedMedia(vault: Vault, media: T): Boolean = - withContext(Dispatchers.IO) { - with(keychainHolder) { - checkVaultFolder(vault) - return@withContext try { - val deleted = vault.mediaFile(media.id).delete() - if (deleted) { - database.getVaultDao().deleteMediaFromVault(vault.uuid, media.id) - } - deleted - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to delete file: ${media.label}") - false - } - } - } - - override suspend fun deleteAllEncryptedMedia( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (failedFiles: List) -> Unit - ): Boolean = withContext(Dispatchers.IO) { - with(keychainHolder) { - checkVaultFolder(vault) - val failedFiles = mutableListOf() - val files = vaultFolder(vault).listFiles() - files?.forEach { file -> - try { - val deleted = file.delete() - if (deleted) { - database.getVaultDao() - .deleteMediaFromVault(vault.uuid, file.nameWithoutExtension.toLong()) - } - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to delete file: ${file.name}") - failedFiles.add(file) - } - } - if (failedFiles.isEmpty()) { - onSuccess() - true - } else { - onFailed(failedFiles) - false - } - } - } - - - override suspend fun getUnmigratedVaultMediaSize(): Int { - return withContext(Dispatchers.IO) { - var size = 0 - with(keychainHolder) { - val uuidRegex = - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$".toRegex() - val vaults = - filesDir.listFiles { it.isDirectory && it.nameWithoutExtension.matches(uuidRegex) } - vaults?.forEach { vaultFolder -> - (vaultFolder.listFiles()?.filter { it.name.endsWith("enc") } - ?: emptyList()).map { file -> - try { - file.decryptKotlin() - } catch (_: Throwable) { - printWarning("Un-migrated media found: ${file.nameWithoutExtension}") - size++ - } - } - } - } - size - } - } - - override suspend fun importPortableVault( - vault: Vault, - base64Key: String, - force: Boolean - ): Boolean = withContext(Dispatchers.IO) { - keychainHolder.importPortableVault(vault, base64Key, force).also { success -> - if (success) { - // Ensure DB entry exists - if (database.getVaultDao().getVault(vault.uuid) == null) { - database.getVaultDao().insertVault(vault) - } - } - } - } - - override suspend fun migrateVaultToPortable( - vault: Vault, - onProgress: (current: Int, total: Int) -> Unit - ): Boolean = withContext(Dispatchers.IO) { - keychainHolder.migrateVaultToPortable(vault, onProgress) - } - - override suspend fun migrateVault() { - /*withContext(Dispatchers.IO) { - printInfo("Vault Migration started") - val databaseStoredVaults = database.getVaultDao().getVaults().firstOrNull() - val databaseStoredEncryptedMedia = database.getVaultDao().getAllMedia().firstOrNull() - printInfo("Database stored vaults: ${databaseStoredVaults?.size}") - printInfo("Database stored encrypted media: ${databaseStoredEncryptedMedia?.size}") - - val keychainStoredVaults = with(keychainHolder) { - filesDir.listFiles() - ?.filter { it.isDirectory && File(it, VAULT_INFO_FILE_NAME).exists() } - ?.mapNotNull { - val vaultInfo = File(it, VAULT_INFO_FILE_NAME) - try { - vaultInfo.decrypt() - } catch (e: Exception) { - e.printStackTrace() - printError("Failed to decrypt file: ${vaultInfo.name}.") - null - } - } - ?: emptyList() - } - printInfo("Keychain stored vaults: ${keychainStoredVaults.size}") - - keychainStoredVaults.forEach { - if (databaseStoredVaults?.find { vault -> vault.uuid == it.uuid } == null) { - printInfo("Vault ${it.uuid} will be added to the database") - database.getVaultDao().insertVault(it) - } - } - - val keychainStoredEncryptedMedia = with(keychainHolder) { - val uuidRegex = - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$".toRegex() - val vaults = - filesDir.listFiles { it.isDirectory && it.nameWithoutExtension.matches(uuidRegex) } - val encryptedMedia = mutableListOf() - vaults?.forEach { vaultFolder -> - (vaultFolder.listFiles()?.filter { it.name.endsWith("enc") } - ?: emptyList()).forEach { file -> - try { - val id = file.nameWithoutExtension.toLong() - if (databaseStoredEncryptedMedia?.find { media -> media.id == id } != null) { - return@forEach - } - val oldEncryptedMedia = file.decrypt() - printInfo("Migrating old encrypted media: ${oldEncryptedMedia.id}") - file.delete() - val encryptedMedia2 = - oldEncryptedMedia.migrate(UUID.fromString(vaultFolder.nameWithoutExtension)) - file.encryptKotlin(encryptedMedia2) - encryptedMedia.add(encryptedMedia2) - } catch (e: Throwable) { - e.printStackTrace() - printError("Failed to decrypt file: ${file.name}.") - } - } - } - encryptedMedia - } - - printInfo("Keychain stored encrypted media: ${keychainStoredEncryptedMedia.size}") - - keychainStoredEncryptedMedia.forEach { - if (databaseStoredEncryptedMedia?.find { media -> media.id == it.id } == null) { - printInfo("Encrypted Media ${it.id} will be added to the database") - database.getVaultDao().addMediaToVault(it) - } - } - - printInfo("Vault Migration finished") - }*/ - } - - override suspend fun restoreVault(vault: Vault) { - val media = database.getVaultDao().getMediaFromVault(vault.uuid).firstOrNull() - media?.forEach { - restoreMedia(vault, it) - } - } - override fun getTimelineSettings(): Flow = database.getMediaDao().getTimelineSettings() @@ -924,7 +904,7 @@ class MediaRepositoryImpl( categoryDao.getCategoryCount() override fun getTopCategories(limit: Int): Flow> = - categoryDao.getTopCategoriesByMediaCount(limit) + categoryDao.getTopCategoriesByMediaCount(limit = limit) override suspend fun updateCategoryThreshold(categoryId: Long, threshold: Float) = categoryDao.updateCategoryThreshold(categoryId, threshold) @@ -1005,7 +985,12 @@ class MediaRepositoryImpl( database.getAlbumThumbnailDao().getAlbumThumbnailsFlow() override suspend fun collectMetadataFor(media: Media) { - context.retrieveExtraMediaMetadata(isolatedParser, geocoder, media)?.let { metadata -> + context.retrieveExtraMediaMetadata( + isolatedParser = isolatedParser, + geocoder = geocoder, + media = media, + usePerFileIsolation = shouldUsePerFileIsolation(), + )?.let { metadata -> database.getMetadataDao().addMetadata(metadata) } } @@ -1018,8 +1003,21 @@ class MediaRepositoryImpl( return database.getImageEmbeddingDao().getRecord(id) } - override fun getImageEmbeddings(): Flow> { - return database.getImageEmbeddingDao().getRecords() + override suspend fun getImageEmbeddingStampPage( + afterId: Long, + limit: Int, + ): List { + return database.getImageEmbeddingDao().getStampPage(afterId = afterId, limit = limit) + } + + override suspend fun getImageEmbeddingPage(afterId: Long, limit: Int): List { + return database.getImageEmbeddingDao().getPage(afterId = afterId, limit = limit) + } + + override suspend fun getImageEmbeddingsByIds(ids: Set): List { + return flatMapIdChunks(ids = ids) { idChunk -> + database.getImageEmbeddingDao().getByIds(ids = idChunk) + } } // ============ Album Groups ============ @@ -1093,9 +1091,31 @@ class MediaRepositoryImpl( override fun getAllCollections(): Flow> = collectionDao.getAllCollections() + /** + * The count and total size are recomputed here against the live media set instead of in SQL. + * The `media` table is only the search index cache and stays empty unless AI media analysis is + * enabled, so a SQL `SUM` over it would report a size of zero for most users. See + * [CollectionWithCount]. + */ override fun getCollectionsWithCount(): Flow> = - collectionDao.getCollectionsWithCount().map { list -> - list.map { it.toCollectionWithCount() } + combine( + collectionDao.getCollectionsWithCount(), + collectionDao.getAllCollectionMedia(), + getMedia().map { it.data.orEmpty() } + ) { collections, membership, media -> + val sizeById = media.associate { it.id to it.size } + val membersByCollection = membership.groupBy { it.collectionId } + collections.map { collection -> + val liveSizes = membersByCollection[collection.collection.id] + ?.mapNotNull { sizeById[it.mediaId] } + .orEmpty() + CollectionWithCount( + collection = collection.collection, + mediaCount = liveSizes.size, + thumbnailMediaId = collection.thumbnailMediaId, + totalSize = liveSizes.sum() + ) + } } override suspend fun updateCollectionLabel(collectionId: Long, label: String) = @@ -1135,7 +1155,7 @@ class MediaRepositoryImpl( override suspend fun addAlbumsToCollection(collectionId: Long, albumIds: List) { collectionDao.addAlbumsToCollection( - albumIds.map { com.dot.gallery.feature_node.domain.model.CollectionAlbum(collectionId, it) } + albumIds.map { com.dot.gallery.feature_node.data.model.CollectionAlbum(collectionId, it) } ) } @@ -1153,4 +1173,4 @@ class MediaRepositoryImpl( put(MediaStore.MediaColumns.RELATIVE_PATH, newPath) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoInfo.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoInfo.kt new file mode 100644 index 0000000000..739ecf9685 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoInfo.kt @@ -0,0 +1,6 @@ +package com.dot.gallery.feature_node.data.repository + +data class MotionPhotoInfo( + val videoOffset: Long, + val presentationTimestampUs: Long = -1L, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarker.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarker.kt new file mode 100644 index 0000000000..58100a57a0 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarker.kt @@ -0,0 +1,40 @@ +package com.dot.gallery.feature_node.data.repository + +import java.io.InputStream +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +private const val MOTION_PHOTO_BUFFER_BYTES = 64 * 1024 +private val MOTION_PHOTO_SAMSUNG_MARKER = "MotionPhoto_Data".toByteArray(Charsets.US_ASCII) + +internal suspend fun findLastMotionPhotoMarkerOffset( + inputStream: InputStream, + bufferSize: Int = MOTION_PHOTO_BUFFER_BYTES, +): Long? { + require(bufferSize > 0) { "Buffer size must be positive" } + val buffer = ByteArray(bufferSize) + var matchedMarkerBytes = 0 + var totalBytes = 0L + var lastMarkerEnd: Long? = null + while (true) { + currentCoroutineContext().ensureActive() + val readBytes = inputStream.read(buffer) + if (readBytes == -1) { + break + } + for (index in 0 until readBytes) { + val value = buffer[index] + matchedMarkerBytes = when (value) { + MOTION_PHOTO_SAMSUNG_MARKER[matchedMarkerBytes] -> matchedMarkerBytes + 1 + MOTION_PHOTO_SAMSUNG_MARKER[0] -> 1 + else -> 0 + } + if (matchedMarkerBytes == MOTION_PHOTO_SAMSUNG_MARKER.size) { + lastMarkerEnd = totalBytes + index + 1L + matchedMarkerBytes = 0 + } + } + totalBytes += readBytes.toLong() + } + return lastMarkerEnd?.let { markerEnd -> totalBytes - markerEnd } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoRepository.kt new file mode 100644 index 0000000000..ddc137b737 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoRepository.kt @@ -0,0 +1,328 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.Log +import com.dot.gallery.core.util.MAX_ENCODED_MEDIA_BYTES +import com.dot.gallery.core.util.SizeLimitedInputStream +import com.dot.gallery.injection.qualifier.IoDispatcher +import com.drew.imaging.ImageMetadataReader +import com.drew.metadata.xmp.XmpDirectory +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +interface MotionPhotoRepository { + + suspend fun parseInfo(uri: Uri): MotionPhotoInfo? + + suspend fun extractVideo(uri: Uri, info: MotionPhotoInfo): File? + + suspend fun extractFrames(file: File, frameCount: Int): List +} + +internal class MotionPhotoRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : MotionPhotoRepository { + + private val contentResolver = context.contentResolver + + override suspend fun parseInfo(uri: Uri): MotionPhotoInfo? { + return withContext(ioDispatcher) { + try { + parseXmpInfo(uri = uri) ?: findSamsungMarkerOffset(uri = uri)?.let { offset -> + MotionPhotoInfo(videoOffset = offset) + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to parse motion photo metadata for $uri", exception) + null + } + } + } + + override suspend fun extractVideo(uri: Uri, info: MotionPhotoInfo): File? { + return withContext(ioDispatcher) { + try { + val sourceSize = getSourceSize(uri = uri) ?: return@withContext null + val requestedFile = extractAtOffset( + uri = uri, + sourceSize = sourceSize, + videoOffset = info.videoOffset, + ) + when { + requestedFile != null -> requestedFile + else -> { + val samsungOffset = findSamsungMarkerOffset(uri = uri) + samsungOffset + ?.takeIf { offset -> offset != info.videoOffset } + ?.let { offset -> + extractAtOffset( + uri = uri, + sourceSize = sourceSize, + videoOffset = offset, + ) + } + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to extract motion photo video for $uri", exception) + null + } + } + } + + override suspend fun extractFrames(file: File, frameCount: Int): List { + return withContext(ioDispatcher) { + if (frameCount <= 0) { + return@withContext emptyList() + } + + val frames = mutableListOf() + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(file.absolutePath) + val durationMicroseconds = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION, + )?.toLongOrNull()?.times(MICROSECONDS_PER_MILLISECOND) ?: 0L + if (durationMicroseconds <= 0L) { + return@withContext emptyList() + } + + val intervalMicroseconds = durationMicroseconds / frameCount + repeat(frameCount) { frameIndex -> + currentCoroutineContext().ensureActive() + val timestamp = frameIndex * intervalMicroseconds + intervalMicroseconds / 2L + retriever.getFrameAtTime( + timestamp, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + )?.let(frames::add) + } + frames + } catch (exception: CancellationException) { + frames.forEach(Bitmap::recycle) + throw exception + } catch (exception: Exception) { + Log.w(TAG, "Failed to extract motion photo frames from $file", exception) + frames + } finally { + runCatching(retriever::release) + } + } + } + + private fun parseXmpInfo(uri: Uri): MotionPhotoInfo? { + val properties: Map = contentResolver.openInputStream(uri)?.use { inputStream -> + val limitedInput = SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = MAX_ENCODED_MEDIA_BYTES, + ) + val metadata = ImageMetadataReader.readMetadata(limitedInput) + val properties = mutableMapOf() + val directories = metadata.getDirectoriesOfType(XmpDirectory::class.java) + for (directory in directories) { + directory.xmpProperties.forEach { (key, value) -> + properties[key] = value + } + } + properties + } ?: return null + + val isMotionPhoto = properties[KEY_MOTION_PHOTO] == ENABLED_VALUE || + properties[KEY_MICRO_VIDEO] == ENABLED_VALUE + if (!isMotionPhoto) { + return null + } + + val videoOffset = explicitMotionPhotoOffset(properties = properties) + ?: containerMotionPhotoOffset(properties = properties) + ?: positiveLong(properties[KEY_MICRO_VIDEO_OFFSET]) + ?: return null + val presentationTimestamp = properties[KEY_MOTION_PHOTO_TIMESTAMP] + ?.toLongOrNull() + ?: properties[KEY_MICRO_VIDEO_TIMESTAMP]?.toLongOrNull() + ?: -1L + return MotionPhotoInfo( + videoOffset = videoOffset, + presentationTimestampUs = presentationTimestamp, + ) + } + + private fun explicitMotionPhotoOffset(properties: Map): Long? { + return positiveLong(properties[KEY_MOTION_PHOTO_OFFSET]) + } + + private fun containerMotionPhotoOffset(properties: Map): Long? { + val semanticEntry = properties.entries.firstOrNull { (key, value) -> + key.endsWith(ITEM_SEMANTIC_SUFFIX) && value == MOTION_PHOTO_SEMANTIC + } ?: return null + val prefix = semanticEntry.key.removeSuffix(ITEM_SEMANTIC_SUFFIX) + val length = positiveLong(properties["${prefix}${ITEM_LENGTH_SUFFIX}"]) ?: return null + val padding = properties["${prefix}${ITEM_PADDING_SUFFIX}"]?.toLongOrNull() ?: 0L + return (length + padding).takeIf { offset -> offset > 0L } + } + + private fun positiveLong(value: String?): Long? { + return value?.toLongOrNull()?.takeIf { number -> number > 0L } + } + + private suspend fun findSamsungMarkerOffset(uri: Uri): Long? { + return contentResolver.openInputStream(uri)?.use { inputStream -> + val limitedInput = SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = MAX_ENCODED_MEDIA_BYTES, + ) + findLastMotionPhotoMarkerOffset(inputStream = limitedInput) + } + } + + private suspend fun getSourceSize(uri: Uri): Long? { + val descriptorLength = contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.length.takeIf { length -> length >= 0L } + } + if (descriptorLength != null) { + return descriptorLength.takeIf { length -> + length in 1L..MAX_ENCODED_MEDIA_BYTES + } + } + + return contentResolver.openInputStream(uri)?.use { inputStream -> + val limitedInput = SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = MAX_ENCODED_MEDIA_BYTES, + ) + countBytes(inputStream = limitedInput) + } + } + + private suspend fun countBytes(inputStream: InputStream): Long { + val buffer = ByteArray(STREAM_BUFFER_BYTES) + var totalBytes = 0L + while (true) { + currentCoroutineContext().ensureActive() + val readBytes = inputStream.read(buffer) + if (readBytes == -1) { + return totalBytes + } + totalBytes += readBytes.toLong() + } + } + + private suspend fun extractAtOffset( + uri: Uri, + sourceSize: Long, + videoOffset: Long, + ): File? { + if (videoOffset !in MINIMUM_MP4_BYTES..sourceSize) { + return null + } + val videoStart = sourceSize - videoOffset + val temporaryFile = File.createTempFile("motion_photo_", ".mp4", context.cacheDir) + val extracted = try { + contentResolver.openInputStream(uri)?.use { inputStream -> + val limitedInput = SizeLimitedInputStream( + inputStream = inputStream, + maximumBytes = MAX_ENCODED_MEDIA_BYTES, + ) + skipExactly(inputStream = limitedInput, byteCount = videoStart) + temporaryFile.outputStream().buffered().use { outputStream -> + copyVerifiedMp4(inputStream = limitedInput, outputStream = outputStream) + } + } == true + } catch (exception: Exception) { + temporaryFile.delete() + throw exception + } + return temporaryFile.takeIf { extracted }.also { result -> + if (result == null) { + temporaryFile.delete() + } + } + } + + private suspend fun skipExactly(inputStream: InputStream, byteCount: Long) { + val buffer = ByteArray(STREAM_BUFFER_BYTES) + var remainingBytes = byteCount + while (remainingBytes > 0L) { + currentCoroutineContext().ensureActive() + val readBytes = inputStream.read( + buffer, + 0, + minOf(buffer.size.toLong(), remainingBytes).toInt(), + ) + if (readBytes == -1) { + throw IOException("Motion photo ended before the embedded video") + } + remainingBytes -= readBytes.toLong() + } + } + + private suspend fun copyVerifiedMp4( + inputStream: InputStream, + outputStream: OutputStream, + ): Boolean { + val header = ByteArray(MP4_HEADER_BYTES) + var headerBytes = 0 + while (headerBytes < header.size) { + val readBytes = inputStream.read(header, headerBytes, header.size - headerBytes) + if (readBytes == -1) { + return false + } + headerBytes += readBytes + } + if (!header.copyOfRange(FTYP_START_INDEX, FTYP_END_INDEX).contentEquals(FTYP_MARKER)) { + return false + } + + outputStream.write(header) + val buffer = ByteArray(STREAM_BUFFER_BYTES) + while (true) { + currentCoroutineContext().ensureActive() + val readBytes = inputStream.read(buffer) + if (readBytes == -1) { + break + } + outputStream.write(buffer, 0, readBytes) + } + outputStream.flush() + return true + } + + companion object { + private const val TAG = "MotionPhotoRepository" + private const val ENABLED_VALUE = "1" + private const val FTYP_END_INDEX = 8 + private const val FTYP_START_INDEX = 4 + private const val ITEM_LENGTH_SUFFIX = "/Item:Length" + private const val ITEM_PADDING_SUFFIX = "/Item:Padding" + private const val ITEM_SEMANTIC_SUFFIX = "/Item:Semantic" + private const val KEY_MICRO_VIDEO = "GCamera:MicroVideo" + private const val KEY_MICRO_VIDEO_OFFSET = "GCamera:MicroVideoOffset" + private const val KEY_MICRO_VIDEO_TIMESTAMP = "GCamera:MicroVideoPresentationTimestampUs" + private const val KEY_MOTION_PHOTO = "GCamera:MotionPhoto" + private const val KEY_MOTION_PHOTO_OFFSET = "GCamera:MotionPhotoVideoOffset" + private const val KEY_MOTION_PHOTO_TIMESTAMP = + "GCamera:MotionPhotoPresentationTimestampUs" + private const val MICROSECONDS_PER_MILLISECOND = 1_000L + private const val MINIMUM_MP4_BYTES = 8L + private const val MOTION_PHOTO_SEMANTIC = "MotionPhoto" + private const val MP4_HEADER_BYTES = 8 + private const val STREAM_BUFFER_BYTES = 64 * 1024 + + private val FTYP_MARKER = "ftyp".toByteArray(Charsets.US_ASCII) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepository.kt new file mode 100644 index 0000000000..6680357c2d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepository.kt @@ -0,0 +1,87 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.net.Uri +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import com.dot.gallery.injection.qualifier.IoDispatcher +import java.util.Locale +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +internal interface SecureReviewMediaRepository { + + suspend fun loadMedia( + request: AuthorizedSecureReviewRequest, + ): ImmutableList? +} + +internal class SecureReviewMediaRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : SecureReviewMediaRepository { + + override suspend fun loadMedia( + request: AuthorizedSecureReviewRequest, + ): ImmutableList? { + return withContext(ioDispatcher) { + val mediaItems = request.uris.mapIndexed { index, uri -> + createReadOnlyMedia( + uri = uri, + index = index, + ) + } + + when { + mediaItems.any { it == null } -> null + else -> mediaItems.filterNotNull().toImmutableList() + } + } + } + + private fun createReadOnlyMedia(uri: Uri, index: Int): Media.UriMedia? { + val mimeType = getMimeType(uri = uri) + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.ROOT) + ?.takeIf { mimeType -> + mimeType.startsWith("image/") || mimeType.startsWith("video/") + } + ?: return null + + return Media.UriMedia( + id = -(index.toLong() + 1L), + label = uri.lastPathSegment.orEmpty(), + uri = uri, + path = "", + relativePath = "", + albumID = SECURE_REVIEW_ALBUM_ID, + albumLabel = "", + timestamp = 0L, + expiryTimestamp = null, + takenTimestamp = null, + fullDate = "", + mimeType = mimeType, + favorite = 0, + trashed = 0, + size = 0L, + duration = null, + ) + } + + private fun getMimeType(uri: Uri): String? { + return try { + contentResolver.getType(uri) + } catch (_: RuntimeException) { + null + } + } + + companion object { + private const val SECURE_REVIEW_ALBUM_ID = -99L + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepository.kt new file mode 100644 index 0000000000..d4b03a6c98 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepository.kt @@ -0,0 +1,167 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import com.dot.gallery.feature_node.data.model.WidgetData +import com.dot.gallery.feature_node.data.model.WidgetType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.json.Json + +interface WidgetRepository { + fun getWidgetData(widgetId: Int): WidgetData? + fun replaceWidgetData(widgetId: Int, type: WidgetType, uris: List): Boolean + fun deleteWidgetData(widgetId: Int): Boolean + fun getMediaUris(widgetId: Int): List +} + +@Singleton +internal class WidgetRepositoryImpl @Inject constructor( + @ApplicationContext context: Context, +) : WidgetRepository { + + private val contentResolver = context.contentResolver + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + override fun getWidgetData(widgetId: Int): WidgetData? { + val encodedData = preferences.getString(widgetKey(widgetId = widgetId), null) ?: return null + return runCatching { + json.decodeFromString(encodedData) + }.getOrNull() + } + + override fun replaceWidgetData(widgetId: Int, type: WidgetType, uris: List): Boolean { + return synchronized(uriOwnershipLock) { + replaceWidgetDataLocked(widgetId = widgetId, type = type, uris = uris) + } + } + + private fun replaceWidgetDataLocked(widgetId: Int, type: WidgetType, uris: List): Boolean { + val previousData = getWidgetData(widgetId = widgetId) + val distinctUris = uris.distinct() + val previouslyPersistedUris = contentResolver.persistedUriPermissions + .asSequence() + .filter { permission -> permission.isReadPermission } + .map { permission -> permission.uri } + .toSet() + + distinctUris.forEach { uri -> + takeReadPermission(uri = uri) + } + + val widgetData = WidgetData( + widgetId = widgetId, + type = type, + mediaUris = distinctUris.map { uri -> uri.toString() }, + ) + val saved = preferences.edit() + .putString(widgetKey(widgetId = widgetId), json.encodeToString(widgetData)) + .commit() + + if (!saved) { + distinctUris + .filterNot { uri -> uri in previouslyPersistedUris } + .forEach { uri -> releaseReadPermissionIfUnused(uri = uri) } + return false + } + + previousData?.mediaUris + ?.asSequence() + ?.map { encodedUri -> encodedUri.toUri() } + ?.filterNot { uri -> uri in distinctUris } + ?.forEach { uri -> releaseReadPermissionIfUnused(uri = uri) } + return true + } + + override fun deleteWidgetData(widgetId: Int): Boolean { + return synchronized(uriOwnershipLock) { + deleteWidgetDataLocked(widgetId = widgetId) + } + } + + private fun deleteWidgetDataLocked(widgetId: Int): Boolean { + val previousData = getWidgetData(widgetId = widgetId) + val deleted = preferences.edit() + .remove(widgetKey(widgetId = widgetId)) + .commit() + + if (deleted) { + previousData?.mediaUris + ?.asSequence() + ?.map { encodedUri -> encodedUri.toUri() } + ?.forEach { uri -> releaseReadPermissionIfUnused(uri = uri) } + } + return deleted + } + + override fun getMediaUris(widgetId: Int): List { + return getWidgetData(widgetId = widgetId) + ?.mediaUris + ?.map { encodedUri -> encodedUri.toUri() } + .orEmpty() + } + + private fun takeReadPermission(uri: Uri) { + try { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (exception: SecurityException) { + Log.w(TAG, "The provider did not grant persistent read access to ${uri}", exception) + } catch (exception: IllegalArgumentException) { + Log.w(TAG, "The provider did not grant persistent read access to ${uri}", exception) + } + } + + private fun releaseReadPermissionIfUnused(uri: Uri) { + val isStillReferenced = storedWidgetData().any { widgetData -> + widgetData.mediaUris.any { encodedUri -> encodedUri == uri.toString() } + } + if (isStillReferenced) { + return + } + + val hasPersistedReadPermission = contentResolver.persistedUriPermissions.any { permission -> + permission.isReadPermission && permission.uri == uri + } + if (!hasPersistedReadPermission) { + return + } + + try { + contentResolver.releasePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (exception: SecurityException) { + Log.w(TAG, "Unable to release persistent read access to ${uri}", exception) + } catch (exception: IllegalArgumentException) { + Log.w(TAG, "Unable to release persistent read access to ${uri}", exception) + } + } + + private fun storedWidgetData(): Sequence { + return preferences.all + .asSequence() + .filter { (key, value) -> key.startsWith(KEY_PREFIX) && value is String } + .mapNotNull { (_, value) -> + runCatching { + json.decodeFromString(value as String) + }.getOrNull() + } + } + + private fun widgetKey(widgetId: Int): String { + return "${KEY_PREFIX}${widgetId}" + } + + private companion object { + private const val TAG = "WidgetRepository" + private const val PREFERENCES_NAME = "gallery_widget_prefs" + private const val KEY_PREFIX = "widget_" + private val uriOwnershipLock = Any() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/Converters.kt similarity index 80% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/util/Converters.kt index 9aecb3b35f..b337a5981b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/Converters.kt @@ -1,10 +1,10 @@ -package com.dot.gallery.feature_node.domain.util +package com.dot.gallery.feature_node.data.util import android.net.Uri import androidx.room.TypeConverter -import com.dot.gallery.feature_node.domain.model.Media -import kotlinx.serialization.json.Json +import com.dot.gallery.feature_node.data.model.Media import java.util.UUID +import kotlinx.serialization.json.Json object Converters { @TypeConverter @@ -37,15 +37,9 @@ object Converters { @TypeConverter fun toUUID(value: String): UUID = UUID.fromString(value) - @TypeConverter - fun fromFloatArray(array: FloatArray): String = Json.encodeToString(array) - - @TypeConverter - fun toFloatArray(value: String): FloatArray = Json.decodeFromString(value) - @TypeConverter fun fromLongList(list: List?): String = Json.encodeToString(list ?: emptyList()) @TypeConverter fun toLongList(value: String?): List = Json.decodeFromString(value ?: "[]") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverter.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverter.kt new file mode 100644 index 0000000000..8a863223f1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverter.kt @@ -0,0 +1,55 @@ +package com.dot.gallery.feature_node.data.util + +import androidx.room.TypeConverter +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Stores an embedding as a little-endian blob of `[magic][dimensions][floats]`. The magic number and + * the length prefix let a row written by a different build be recognised as unreadable instead of + * being decoded into plausible-looking noise. + */ +object EmbeddingBlobConverter { + + private const val MAGIC = 0x31424D45 + private const val HEADER_SIZE_BYTES = Int.SIZE_BYTES * 2 + private const val MAX_DIMENSIONS = 16_384 + + @TypeConverter + fun encode(embedding: FloatArray): ByteArray { + require(embedding.isNotEmpty()) { "Embedding must not be empty" } + require(embedding.size <= MAX_DIMENSIONS) { + "Embedding contains too many dimensions: ${embedding.size}" + } + require(embedding.all { value -> value.isFinite() }) { + "Embedding contains a non-finite value" + } + return ByteBuffer.allocate(HEADER_SIZE_BYTES + embedding.size * Float.SIZE_BYTES) + .order(ByteOrder.LITTLE_ENDIAN) + .apply { + putInt(MAGIC) + putInt(embedding.size) + embedding.forEach { value -> putFloat(value) } + } + .array() + } + + @TypeConverter + fun decode(data: ByteArray): FloatArray { + require(data.size >= HEADER_SIZE_BYTES) { "Embedding data is truncated" } + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + require(buffer.int == MAGIC) { "Embedding data has an unsupported format" } + + val dimensions = buffer.int + require(dimensions in 1..MAX_DIMENSIONS) { + "Invalid embedding dimension count: ${dimensions}" + } + val expectedSize = HEADER_SIZE_BYTES.toLong() + dimensions.toLong() * Float.SIZE_BYTES + require(data.size.toLong() == expectedSize) { "Embedding data has an invalid size" } + return FloatArray(dimensions) { buffer.float }.also { embedding -> + require(embedding.all { value -> value.isFinite() }) { + "Embedding contains a non-finite value" + } + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaExt.kt similarity index 61% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaExt.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaExt.kt index 29ca4b56b6..1f1eee0bcd 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaExt.kt @@ -1,19 +1,12 @@ -package com.dot.gallery.feature_node.domain.util +package com.dot.gallery.feature_node.data.util -import android.content.Context -import android.graphics.Bitmap import android.net.Uri -import com.dot.gallery.BuildConfig -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.github.panpf.zoomimage.subsampling.ContentImageSource -import com.github.panpf.zoomimage.subsampling.SubsamplingImage +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.PinnedAlbum import io.ktor.util.reflect.instanceOf -import kotlinx.serialization.json.Json -import java.util.UUID /** * Determine if the current media is a raw format @@ -72,158 +65,28 @@ val Media.volume: String */ val Media.readUriOnly: Boolean get() = albumID == -99L && albumLabel == "" && instanceOf(Media.UriMedia::class) -val Media.isVideo: Boolean get() = mimeType.startsWith("video/") && duration != null +val Media.isVideo: Boolean get() = mimeType.startsWith("video/") && (duration != null || readUriOnly) val Media.isImage: Boolean get() = mimeType.startsWith("image/") -/** - * Returns true if this media is a raw file format that should not be converted - * through bitmap encoding/decoding (e.g., GIF, animated WebP). - * These formats would lose animation or quality if processed as bitmaps. - */ -val Media.isRawFile: Boolean get() = mimeType in listOf( - "image/gif", - "image/webp", // WebP can be animated - "image/svg+xml", - "image/bmp" -) - val Media.isTrashed: Boolean get() = trashed == 1 val Media.isFavorite: Boolean get() = favorite == 1 -val Media.isEncrypted: Boolean - get() = instanceOf(Media.UriMedia::class) && getUri().toString() - .contains(BuildConfig.APPLICATION_ID) - val Media.isLocalContent: Boolean get() = instanceOf(Media.UriMedia::class) && getUri().toString().startsWith("content://media") -val Media.canMakeActions: Boolean get() = !isEncrypted && isLocalContent && !instanceOf(Media.ClassifiedMedia::class) && !readUriOnly - -val Media.isClassified: Boolean get() = instanceOf(Media.ClassifiedMedia::class) +val Media.canMakeActions: Boolean get() = isLocalContent && !instanceOf(Media.ClassifiedMedia::class) && !readUriOnly val Media.getCategory: String? get() = if (this is Media.ClassifiedMedia) { this.category } else null -/* -@Suppress("UNCHECKED_CAST") -fun fromByteArray(byteArray: ByteArray): T { - ByteArrayInputStream(byteArray).use { byteArrayInputStream -> - ObjectInputStream(byteArrayInputStream).use { objectInput -> - return objectInput.readObject() as T - } - } -} -*/ - -@Suppress("UNCHECKED_CAST") -inline fun fromKotlinByteArray(byteArray: ByteArray): T = - Json.decodeFromString(String(byteArray, Charsets.UTF_8)) - -inline fun T.toKotlinByteArray() = Json.encodeToString(this).toByteArray(Charsets.UTF_8) - -fun Media.EncryptedMedia.migrate(uuid: UUID): Media.EncryptedMedia2 = Media.EncryptedMedia2( - id = id, - label = label, - uuid = uuid, - path = path, - timestamp = timestamp, - mimeType = mimeType, - duration = duration, - trashed = trashed, - favorite = favorite, - albumID = albumID, - albumLabel = albumLabel, - relativePath = relativePath, - fullDate = fullDate, - size = size, -) - -fun T.toEncryptedMedia(bytes: ByteArray): Media.EncryptedMedia { - return Media.EncryptedMedia( - id = id, - label = label, - bytes = bytes, - path = path, - timestamp = timestamp, - mimeType = mimeType, - duration = duration, - trashed = trashed, - favorite = favorite, - albumID = albumID, - albumLabel = albumLabel, - relativePath = relativePath, - fullDate = fullDate, - size = size, - ) -} - -fun T.toEncryptedMedia2(uuid: UUID): Media.EncryptedMedia2 { - return Media.EncryptedMedia2( - id = id, - label = label, - uuid = uuid, - path = path, - timestamp = timestamp, - mimeType = mimeType, - duration = duration, - trashed = trashed, - favorite = favorite, - albumID = albumID, - albumLabel = albumLabel, - relativePath = relativePath, - fullDate = fullDate, - size = size, - ) -} - -fun T.asSubsamplingImage(context: Context): SubsamplingImage { - return SubsamplingImage(imageSource = ContentImageSource(context, getUri())) -} - -fun T.compatibleMimeType(): String { - return if (isImage) when (mimeType) { - "image/jpeg" -> "image/jpeg" - "image/png" -> "image/png" - else -> "image/png" - } else mimeType -} - -fun T.compatibleBitmapFormat(): Bitmap.CompressFormat { - return when (mimeType) { - "image/jpeg" -> Bitmap.CompressFormat.JPEG - "image/png" -> Bitmap.CompressFormat.PNG - else -> Bitmap.CompressFormat.PNG - } -} - -fun T.asUriMedia(uri: Uri): Media.UriMedia { - return Media.UriMedia( - id = id, - label = label, - uri = uri, - path = path, - timestamp = timestamp, - mimeType = mimeType, - duration = duration, - trashed = trashed, - favorite = favorite, - albumID = albumID, - albumLabel = albumLabel, - relativePath = relativePath, - fullDate = fullDate, - size = size, - ) -} - fun T.getUri(): Uri { return when (this) { is Media.UriMedia -> uri is Media.ClassifiedMedia -> uri - else -> throw IllegalArgumentException("Media type ${this.javaClass.simpleName} not supported") } } @@ -282,7 +145,12 @@ val Media.groupBaseName: String // Fall back to generic suffix stripping for RAW pairs, edits, etc. return nameWithoutExt // Pixel-style dot-separated suffixes - .replace(Regex("\\.(ORIGINAL|RAW-\\d+|NIGHT|PORTRAIT|LONG_EXPOSURE|MP|MOTION-\\d+|PANO|TOP|BOTTOM|COVER|BURST\\d*)", RegexOption.IGNORE_CASE), "") + .replace( + Regex( + "\\.(ORIGINAL|RAW-\\d+|NIGHT|PORTRAIT|LONG_EXPOSURE|MP|MOTION-\\d+|PANO|TOP|BOTTOM|COVER|BURST\\d*)", + RegexOption.IGNORE_CASE + ), "" + ) // Copy / duplicate suffixes .replace(Regex("\\(\\d+\\)$"), "") // (1), (2), etc. .replace(Regex("~\\d+$"), "") // ~2, ~3, etc. @@ -346,13 +214,16 @@ fun List.classifyGroupType(): MediaGroupType { return MediaGroupType.EDITS } -fun List.mapPinned(pinnedAlbums: List): List = - map { album -> album.copy(isPinned = pinnedAlbums.any { it.id == album.id }) } +fun List.mapPinned(pinnedAlbums: List): List { + return map { album -> album.copy(isPinned = pinnedAlbums.any { it.id == album.id }) } +} -fun List.mapLocked(lockedAlbums: List): List = - map { album -> album.copy(isLocked = lockedAlbums.any { it.id == album.id }) } +fun List.mapLocked(lockedAlbums: List): List { + return map { album -> album.copy(isLocked = lockedAlbums.any { it.id == album.id }) } +} -fun List.removeBlacklisted(blacklistedAlbums: List): List = - toMutableList().apply { +fun List.removeBlacklisted(blacklistedAlbums: List): List { + return toMutableList().apply { removeAll { album -> blacklistedAlbums.any { it.matchesAlbum(album) && it.hiddenInAlbums } } - } \ No newline at end of file + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaOrder.kt similarity index 78% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaOrder.kt index c00f1a66d9..f0a0852111 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaOrder.kt @@ -3,34 +3,43 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.domain.util +package com.dot.gallery.feature_node.data.util -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Persisted in the `timeline_settings` table as a polymorphic payload keyed by the serial name of + * the concrete case. As with [OrderType], those names are pinned to the package this type used to + * live in so that rows written by earlier versions still decode. + */ @Serializable sealed class MediaOrder(open val orderType: OrderType) { @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.MediaOrder.Label") data class Label( @SerialName("orderType_label") override val orderType: OrderType ) : MediaOrder(orderType) @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.MediaOrder.Date") data class Date( @SerialName("orderType_date") override val orderType: OrderType ) : MediaOrder(orderType) @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.MediaOrder.DateModified") data class DateModified( @SerialName("orderType_date_modified") override val orderType: OrderType ) : MediaOrder(orderType) @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.MediaOrder.Expiry") data class Expiry( @SerialName("orderType_expiry") override val orderType: OrderType = OrderType.Descending diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolver.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolver.kt new file mode 100644 index 0000000000..e453bb227c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolver.kt @@ -0,0 +1,37 @@ +package com.dot.gallery.feature_node.data.util + +import android.os.Environment +import android.provider.MediaStore + +/** + * Resolves a destination path (absolute or relative) into a pair of + * (MediaStore volume name, relative path). + * + * - Absolute paths like `/storage/emulated/0/DCIM/Camera/` resolve to + * `(VOLUME_EXTERNAL_PRIMARY, "DCIM/Camera/")` + * - SD card paths like `/storage/71F8-2C0A/DCIM/Camera/` resolve to + * `("71f8-2c0a", "DCIM/Camera/")` + * - Relative paths like `DCIM/Camera/` resolve to + * `(VOLUME_EXTERNAL_PRIMARY, "DCIM/Camera/")` + */ +internal fun resolveMediaStoreVolume(path: String): Pair { + val primaryStorage = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + + return when { + path.startsWith("$primaryStorage/") -> { + val relativePath = path.removePrefix("$primaryStorage/") + MediaStore.VOLUME_EXTERNAL_PRIMARY to relativePath + } + + path.startsWith("/storage/") -> { + val afterStorage = path.removePrefix("/storage/") + val volumeId = afterStorage.substringBefore("/").lowercase() + val relativePath = afterStorage.substringAfter("/", "") + volumeId to relativePath + } + + else -> { + MediaStore.VOLUME_EXTERNAL_PRIMARY to path + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/OrderType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/OrderType.kt new file mode 100644 index 0000000000..1f15a2e814 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/OrderType.kt @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.util + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Persisted in the `timeline_settings` table and in the sort preferences, as a polymorphic payload + * keyed by the serial name of the concrete case. The names are pinned to the package this type used + * to live in so that data written by earlier versions still decodes — they are a storage format, not + * a reflection of where the class sits today, and must not be updated to follow it. + */ +@Serializable +@Parcelize +sealed class OrderType : Parcelable { + @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.OrderType.Ascending") + @Parcelize + data object Ascending : OrderType() + + @Serializable + @SerialName("com.dot.gallery.feature_node.domain.util.OrderType.Descending") + @Parcelize + data object Descending : OrderType() +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/UriSerializer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/UriSerializer.kt similarity index 93% rename from app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/UriSerializer.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/data/util/UriSerializer.kt index b09be1dfd6..cdbb1cfc89 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/UriSerializer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/util/UriSerializer.kt @@ -1,4 +1,4 @@ -package com.dot.gallery.feature_node.domain.util +package com.dot.gallery.feature_node.data.util import android.net.Uri import androidx.core.net.toUri diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AiMediaAnalysisWorkState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AiMediaAnalysisWorkState.kt new file mode 100644 index 0000000000..a8da587fd0 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AiMediaAnalysisWorkState.kt @@ -0,0 +1,9 @@ +package com.dot.gallery.feature_node.domain.model + +internal data class AiMediaAnalysisWorkState( + val analysisProgress: Float? = null, + val isAnalysisActive: Boolean = false, + val categoryProgress: Float = 0f, + val categoryStatus: String = "", + val isCategoryActive: Boolean = false, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupWithAlbums.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupWithAlbums.kt index 066acc9dcb..04ed0116dd 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupWithAlbums.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumGroupWithAlbums.kt @@ -5,6 +5,8 @@ package com.dot.gallery.feature_node.domain.model import androidx.compose.runtime.Stable +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.AlbumGroup @Stable data class AlbumGroupWithAlbums( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt index d89690a0f2..34144af6ab 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt @@ -1,6 +1,8 @@ package com.dot.gallery.feature_node.domain.model import androidx.compose.runtime.Stable +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.CollectionWithCount @Stable data class AlbumState( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Collection.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Collection.kt deleted file mode 100644 index 9fbc9b60d8..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Collection.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ -package com.dot.gallery.feature_node.domain.model - -import androidx.compose.runtime.Immutable -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.serialization.Serializable - -@Entity(tableName = "collections") -@Immutable -@Serializable -data class Collection( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val label: String, - val coverMediaId: Long? = null, - val isPinned: Boolean = false, - val sortOrder: Int = 0, - val createdAt: Long = System.currentTimeMillis(), - val updatedAt: Long = System.currentTimeMillis() -) - -/** - * Represents a collection with its associated media count and thumbnail. - * Used for displaying collections in the UI. - */ -data class CollectionWithCount( - val collection: Collection, - val mediaCount: Int, - val thumbnailMediaId: Long?, - val totalSize: Long = 0 -) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EditedMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EditedMedia.kt deleted file mode 100644 index 75de5831b2..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EditedMedia.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.model - -import android.net.Uri -import androidx.room.Entity -import androidx.room.PrimaryKey -import com.dot.gallery.feature_node.domain.util.UriSerializer -import kotlinx.serialization.Serializable - -@Entity(tableName = "edited_media") -data class EditedMedia( - @PrimaryKey - val mediaId: Long, - @Serializable(with = UriSerializer::class) - val originalUri: Uri, - val backupPath: String, - val originalMimeType: String, - val editTimestamp: Long = System.currentTimeMillis() -) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/GeoMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/GeoMedia.kt deleted file mode 100644 index 25055902bf..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/GeoMedia.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import androidx.compose.runtime.Stable - -@Stable -data class GeoMedia( - val mediaId: Long, - val latitude: Double, - val longitude: Double, - val locationCity: String?, - val locationCountry: String?, - val media: Media.UriMedia -) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt index 7855336d4c..6956becefb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.dot.gallery.feature_node.data.model.MediaMetadata import com.dot.gallery.feature_node.presentation.util.formattedAddress import com.dot.gallery.feature_node.presentation.util.getLocation import com.dot.gallery.feature_node.presentation.util.rememberGeocoder diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationMedia.kt deleted file mode 100644 index 1dbbb7d3fd..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationMedia.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import androidx.compose.runtime.Stable -import kotlinx.serialization.Serializable - -@Stable -@Serializable -data class LocationMedia( - val media: Media, - val location: String -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt deleted file mode 100644 index d63b3fe23b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.model - -import android.content.Context -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.os.Parcelable -import android.webkit.MimeTypeMap -import androidx.room.Entity -import com.dot.gallery.core.Constants -import com.dot.gallery.feature_node.domain.util.UriSerializer -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.util.getDate -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable -import java.io.File -import java.util.UUID -import kotlin.random.Random - -@Serializable -@Parcelize -sealed class Media : Parcelable { - - - abstract val id: Long - abstract val label: String - abstract val path: String - abstract val relativePath: String - abstract val albumID: Long - abstract val albumLabel: String - abstract val timestamp: Long - abstract val expiryTimestamp: Long? - abstract val takenTimestamp: Long? - abstract val fullDate: String - abstract val mimeType: String - abstract val favorite: Int - abstract val trashed: Int - abstract val size: Long - abstract val duration: String? - - val definedTimestamp: Long - get() = takenTimestamp?.div(1000) ?: timestamp - - val key: String - get() = "{$id, ${try { getUri() } catch (_: Exception) { path} }, $definedTimestamp}" - - val idLessKey: String - get() = "{${try { getUri() } catch (_: Exception) { path} }, $definedTimestamp}" - - @Serializable - @Parcelize - @Entity(tableName = "media", primaryKeys = ["id"]) - data class UriMedia( - override val id: Long = 0, - override val label: String, - @Serializable(with = UriSerializer::class) - val uri: Uri, - override val path: String, - override val relativePath: String, - override val albumID: Long, - override val albumLabel: String, - override val timestamp: Long, - override val expiryTimestamp: Long? = null, - override val takenTimestamp: Long? = null, - override val fullDate: String, - override val mimeType: String, - override val favorite: Int, - override val trashed: Int, - override val size: Long, - override val duration: String? = null - ) : Media() - - @Serializable - @Parcelize - @Entity(tableName = "classified_media", primaryKeys = ["id"]) - data class ClassifiedMedia( - override val id: Long = 0, - override val label: String, - @Serializable(with = UriSerializer::class) - val uri: Uri, - override val path: String, - override val relativePath: String, - override val albumID: Long, - override val albumLabel: String, - override val timestamp: Long, - override val expiryTimestamp: Long?, - override val takenTimestamp: Long?, - override val fullDate: String, - override val mimeType: String, - override val favorite: Int, - override val trashed: Int, - override val size: Long, - override val duration: String?, - val category: String?, - val score: Float, - ): Media() - - @Serializable - @Parcelize - data class EncryptedMedia( - override val id: Long = 0, - override val label: String, - val bytes: ByteArray, - override val path: String, - override val relativePath: String, - override val albumID: Long, - override val albumLabel: String, - override val timestamp: Long, - override val expiryTimestamp: Long? = null, - override val takenTimestamp: Long? = null, - override val fullDate: String, - override val mimeType: String, - override val favorite: Int, - override val trashed: Int, - override val size: Long, - override val duration: String? = null - ): Media() { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptedMedia - - if (id != other.id) return false - if (label != other.label) return false - if (!bytes.contentEquals(other.bytes)) return false - if (path != other.path) return false - if (relativePath != other.relativePath) return false - if (albumID != other.albumID) return false - if (albumLabel != other.albumLabel) return false - if (timestamp != other.timestamp) return false - if (expiryTimestamp != other.expiryTimestamp) return false - if (takenTimestamp != other.takenTimestamp) return false - if (fullDate != other.fullDate) return false - if (mimeType != other.mimeType) return false - if (favorite != other.favorite) return false - if (trashed != other.trashed) return false - if (size != other.size) return false - if (duration != other.duration) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + label.hashCode() - result = 31 * result + bytes.contentHashCode() - result = 31 * result + path.hashCode() - result = 31 * result + relativePath.hashCode() - result = 31 * result + albumID.hashCode() - result = 31 * result + albumLabel.hashCode() - result = 31 * result + timestamp.hashCode() - result = 31 * result + (expiryTimestamp?.hashCode() ?: 0) - result = 31 * result + (takenTimestamp?.hashCode() ?: 0) - result = 31 * result + fullDate.hashCode() - result = 31 * result + mimeType.hashCode() - result = 31 * result + favorite - result = 31 * result + trashed - result = 31 * result + size.hashCode() - result = 31 * result + (duration?.hashCode() ?: 0) - return result - } - } - - @Serializable - @Parcelize - @Entity(tableName = "encrypted_media", primaryKeys = ["id"]) - data class EncryptedMedia2( - override val id: Long = 0, - override val label: String, - @Serializable(with = UUIDSerializer::class) - val uuid: UUID, - override val path: String, - override val relativePath: String, - override val albumID: Long, - override val albumLabel: String, - override val timestamp: Long, - override val expiryTimestamp: Long? = null, - override val takenTimestamp: Long? = null, - override val fullDate: String, - override val mimeType: String, - override val favorite: Int, - override val trashed: Int, - override val size: Long, - override val duration: String? = null - ): Media() { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptedMedia - - if (id != other.id) return false - if (label != other.label) return false - if (path != other.path) return false - if (relativePath != other.relativePath) return false - if (albumID != other.albumID) return false - if (albumLabel != other.albumLabel) return false - if (timestamp != other.timestamp) return false - if (expiryTimestamp != other.expiryTimestamp) return false - if (takenTimestamp != other.takenTimestamp) return false - if (fullDate != other.fullDate) return false - if (mimeType != other.mimeType) return false - if (favorite != other.favorite) return false - if (trashed != other.trashed) return false - if (size != other.size) return false - if (duration != other.duration) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + label.hashCode() - result = 31 * result + path.hashCode() - result = 31 * result + relativePath.hashCode() - result = 31 * result + albumID.hashCode() - result = 31 * result + albumLabel.hashCode() - result = 31 * result + timestamp.hashCode() - result = 31 * result + (expiryTimestamp?.hashCode() ?: 0) - result = 31 * result + (takenTimestamp?.hashCode() ?: 0) - result = 31 * result + fullDate.hashCode() - result = 31 * result + mimeType.hashCode() - result = 31 * result + favorite - result = 31 * result + trashed - result = 31 * result + size.hashCode() - result = 31 * result + (duration?.hashCode() ?: 0) - return result - } - } - - companion object { - fun createFromUri(context: Context?, uri: Uri): UriMedia? { - if (uri.path == null) return null - val extension = uri.toString().substringAfterLast(".") - var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - var duration: String? = null - var timestamp = 0L - uri.path?.let { File(it) }?.let { - timestamp = try { - it.lastModified() - } catch (_: Exception) { - 0L - } - } - if (context != null) { - try { - val retriever = MediaMetadataRetriever().apply { - setDataSource(context, uri) - } - val hasVideo = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) - val isVideo = "yes" == hasVideo - if (isVideo) { - duration = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - if (timestamp == 0L) { - timestamp = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)?.toLongOrNull() ?: 0L - } - } - val originMimeType = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) - if (mimeType == null) { - mimeType = if (isVideo) originMimeType else "image/*" - } - } catch (e: Exception) { - e.printStackTrace() - } - } - var formattedDate = "" - if (timestamp != 0L) { - formattedDate = timestamp.getDate(Constants.EXTENDED_DATE_FORMAT) - } - return UriMedia( - id = Random(System.currentTimeMillis()).nextLong(-1000, 25600000), - label = uri.toString().substringAfterLast("/"), - uri = uri, - path = uri.path.toString(), - relativePath = uri.path.toString().substringBeforeLast("/"), - albumID = -99L, - albumLabel = "", - timestamp = timestamp, - fullDate = formattedDate, - mimeType = mimeType ?: "null", - duration = duration, - favorite = 0, - size = 0, - trashed = 0 - ) - } - } - -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt index fd255b5997..4885b0245b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.rememberExifDateFormat +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata import com.dot.gallery.feature_node.presentation.util.getDate @Stable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadataState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadataState.kt index ea85866c21..ee5b722afe 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadataState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaMetadataState.kt @@ -1,7 +1,12 @@ package com.dot.gallery.feature_node.domain.model +import com.dot.gallery.feature_node.data.model.MediaMetadata +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + data class MediaMetadataState( val metadata: List = emptyList(), + val metadataById: ImmutableMap = persistentMapOf(), val isLoading: Boolean = false, val isLoadingProgress: Int = 0, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt index 42cd843261..817102a0d6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt @@ -1,6 +1,8 @@ package com.dot.gallery.feature_node.domain.model import androidx.compose.runtime.Stable +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem @Stable data class MediaState( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt deleted file mode 100644 index cfb3970add..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import android.net.Uri -import android.provider.MediaStore - -enum class MediaType( - val externalContentUri: Uri, - val mediaStoreValue: Int, -) { - IMAGE( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE, - ), - VIDEO( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO, - ); - - companion object { - private val dashMimeTypes = listOf( - "application/dash+xml", - ) - - private val hlsMimeTypes = listOf( - "application/vnd.apple.mpegurl", - "application/x-mpegurl", - "audio/mpegurl", - "audio/x-mpegurl", - ) - - private val smoothStreamingMimeTypes = listOf( - "application/vnd.ms-sstr+xml", - ) - - fun fromMediaStoreValue(value: Int) = entries.first { - value == it.mediaStoreValue - } - - fun fromMimeType(mimeType: String) = when { - mimeType.startsWith("image/") -> IMAGE - mimeType.startsWith("video/") -> VIDEO - mimeType in dashMimeTypes -> VIDEO - mimeType in hlsMimeTypes -> VIDEO - mimeType in smoothStreamingMimeTypes -> VIDEO - else -> null - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MosaicDisplayItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MosaicDisplayItem.kt index 4bcf31d050..462db265aa 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MosaicDisplayItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MosaicDisplayItem.kt @@ -5,6 +5,9 @@ package com.dot.gallery.feature_node.domain.model +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem + /** * Tile size variants for the mosaic layout. * diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/SelectionAction.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/SelectionAction.kt index e83b1059e5..dcc25db7d7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/SelectionAction.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/SelectionAction.kt @@ -7,15 +7,14 @@ package com.dot.gallery.feature_node.domain.model import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.DriveFileMove +import androidx.compose.material.icons.automirrored.outlined.RotateRight import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.EnhancedEncryption import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.automirrored.outlined.RotateRight import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.Share import androidx.compose.ui.graphics.vector.ImageVector @@ -98,11 +97,6 @@ enum class SelectionAction( descriptionRes = R.string.action_desc_trash, zone = ActionZone.BOTTOM, ), - ADD_TO_VAULT( - labelRes = R.string.hide, - descriptionRes = R.string.action_desc_hide, - zone = ActionZone.BOTTOM, - ), EDIT( labelRes = R.string.edit, descriptionRes = R.string.action_desc_edit, @@ -126,7 +120,6 @@ enum class SelectionAction( COPY -> Icons.Outlined.CopyAll MOVE -> Icons.AutoMirrored.Outlined.DriveFileMove TRASH -> Icons.Outlined.DeleteOutline - ADD_TO_VAULT -> Icons.Outlined.EnhancedEncryption EDIT -> Icons.Outlined.Edit ROTATE -> Icons.AutoMirrored.Outlined.RotateRight } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt deleted file mode 100644 index bb530b176c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import android.os.Parcelable -import androidx.room.Entity -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import java.util.UUID - -@Parcelize -@Serializable -@Entity(tableName = "vaults", primaryKeys = ["uuid"]) -data class Vault( - @Serializable(with = UUIDSerializer::class) - val uuid: UUID = UUID.randomUUID(), - val name: String -): Parcelable - -object UUIDSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: UUID) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): UUID { - return UUID.fromString(decoder.decodeString()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultInfo.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultInfo.kt deleted file mode 100644 index a813f25ce7..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultInfo.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import kotlinx.serialization.Serializable - -/** - * Metadata stored (likely unencrypted) alongside a vault to indicate whether it is - * transferable (portable) and to hold an integrity hash of the data key. - * version: schema version (increment if structure changes) - * transferable: if true, a separate Data Key (DK) is used to encrypt content and can be exported. - * dkHash: Base64(SHA-256(dataKey)) for integrity / verification when importing. - * uuid: vault UUID string for cross-checking folder naming vs metadata. - */ -@Serializable -data class VaultInfo( - val version: Int = 1, - val transferable: Boolean = false, - val dkHash: String? = null, - val uuid: String -) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt deleted file mode 100644 index c247bde5b1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import androidx.compose.runtime.Stable - -@Stable -data class VaultState( - val vaults: List = emptyList(), - val isLoading: Boolean = true -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/MarkupItems.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/MarkupItems.kt index 74954f1d19..29f8acd09a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/MarkupItems.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/MarkupItems.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import com.dot.gallery.R +import com.dot.gallery.ui.core.Icons as DotIcons import com.dot.gallery.ui.core.icons.InkHighlighter import com.dot.gallery.ui.core.icons.InkMarker import com.dot.gallery.ui.core.icons.Ink_Eraser @@ -15,7 +16,6 @@ import com.dot.gallery.ui.core.icons.Stylus import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -import com.dot.gallery.ui.core.Icons as DotIcons @Serializable @Parcelize diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/SaveFormat.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/SaveFormat.kt deleted file mode 100644 index 6a28b2f3f3..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/editor/SaveFormat.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.dot.gallery.feature_node.domain.model.editor - -import android.graphics.Bitmap.CompressFormat -import androidx.annotation.Keep - -@Keep -sealed interface SaveFormat { - - val format: CompressFormat - val mimeType: String - - data object PNG : SaveFormat { - override val format = CompressFormat.PNG - override val mimeType = "image/png" - } - - data object JPEG : SaveFormat { - override val format = CompressFormat.JPEG - override val mimeType = "image/jpeg" - } - - data object WEBP_LOSSLESS : SaveFormat { - override val format = CompressFormat.WEBP_LOSSLESS - override val mimeType = "image/webp" - } - - data object WEBP_LOSSY : SaveFormat { - override val format = CompressFormat.WEBP_LOSSY - override val mimeType = "image/webp" - } - -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt deleted file mode 100644 index d388c06caf..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.repository - -import android.graphics.Bitmap -import android.net.Uri -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.datastore.preferences.core.Preferences -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember -import com.dot.gallery.feature_node.domain.model.AlbumThumbnail -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.Collection -import com.dot.gallery.feature_node.domain.model.CollectionMedia -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.ClassifiedMedia -import com.dot.gallery.feature_node.domain.model.Media.UriMedia -import com.dot.gallery.feature_node.domain.model.MediaCategory -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.presentation.picker.AllowedMedia -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface MediaRepository { - - suspend fun updateInternalDatabase() - - fun getMedia(): Flow>> - - fun getCompleteMedia(): Flow>> - - fun getMediaByType(allowedMedia: AllowedMedia): Flow>> - - fun getFavorites(mediaOrder: MediaOrder): Flow>> - - fun getTrashed(): Flow>> - - fun getAlbums(mediaOrder: MediaOrder): Flow>> - - fun getAlbum(albumId: Long): Flow> - - suspend fun insertPinnedAlbum(pinnedAlbum: PinnedAlbum) - - suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) - - fun getPinnedAlbums(): Flow> - - suspend fun insertLockedAlbum(lockedAlbum: LockedAlbum) - - suspend fun removeLockedAlbum(lockedAlbum: LockedAlbum) - - fun getLockedAlbums(): Flow> - - suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) - - suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) - - fun getBlacklistedAlbums(): Flow> - - suspend fun getBlacklistedAlbumsAsync(): List - - fun getMediaByAlbumId(albumId: Long): Flow>> - - fun getMediaByAlbumIdWithType( - albumId: Long, - allowedMedia: AllowedMedia - ): Flow>> - - fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> - - fun getMediaListByUris(listOfUris: List, reviewMode: Boolean, onlyMatching: Boolean = false): Flow>> - - suspend fun toggleFavorite( - result: ActivityResultLauncher, - mediaList: List, - favorite: Boolean - ) - - suspend fun trashMedia( - result: ActivityResultLauncher, - mediaList: List, - trash: Boolean - ) - - suspend fun copyMedia( - from: T, - path: String - ) - - suspend fun copyMedia(vararg sets: Pair) - - suspend fun deleteMedia( - result: ActivityResultLauncher, - mediaList: List - ) - - suspend fun renameMedia( - media: T, - newName: String - ): Boolean - - suspend fun moveMedia( - media: T, - newPath: String - ): Boolean - - suspend fun deleteMediaMetadata(media: T): Boolean - - suspend fun deleteMediaGPSMetadata(media: T): Boolean - - suspend fun updateMediaDescription( - media: T, - description: String - ): Boolean - - suspend fun saveImage( - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ): Uri? - - suspend fun overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ): Boolean - - fun getVaults(): Flow>> - - suspend fun createVault( - vault: Vault, - transferable: Boolean = false, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) - - suspend fun deleteVault( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) - - fun getEncryptedMedia(vault: Vault?): Flow>> - - suspend fun addMedia(vault: Vault, media: T): Boolean - - suspend fun restoreMedia(vault: Vault, media: T): Boolean - - suspend fun transferMedia(sourceVault: Vault, targetVault: Vault, media: T, copy: Boolean): Boolean - - suspend fun deleteEncryptedMedia(vault: Vault, media: T): Boolean - - suspend fun deleteAllEncryptedMedia( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (failedFiles: List) -> Unit - ): Boolean - - suspend fun getUnmigratedVaultMediaSize(): Int - - suspend fun importPortableVault( - vault: Vault, - base64Key: String, - force: Boolean = false - ): Boolean - - suspend fun migrateVaultToPortable( - vault: Vault, - onProgress: (current: Int, total: Int) -> Unit = { _, _ -> } - ): Boolean - - suspend fun migrateVault() - - suspend fun restoreVault(vault: Vault) - - fun getTimelineSettings(): Flow - - suspend fun updateTimelineSettings(settings: TimelineSettings) - - fun getSetting(key: Preferences.Key, defaultValue: Result): Flow - - // ============ Legacy Classification (to be deprecated) ============ - fun getClassifiedCategories(): Flow> - - fun getClassifiedMediaByCategory(category: String?): Flow> - - fun getClassifiedMediaByMostPopularCategory(): Flow> - - fun getCategoriesWithMedia(): Flow> - - fun getClassifiedMediaCount(): Flow - - fun getClassifiedMediaCountAtCategory(category: String): Flow - - fun getClassifiedMediaThumbnailByCategory(category: String): Flow - - suspend fun getCategoryForMediaId(mediaId: Long): String? - suspend fun changeCategory(mediaId: Long, newCategory: String) - - suspend fun deleteClassifications() - - // ============ New Category System ============ - - // Category CRUD - suspend fun createCategory(category: Category): Long - suspend fun updateCategory(category: Category) - suspend fun deleteCategory(categoryId: Long) - fun getCategory(categoryId: Long): Flow - suspend fun getCategoryAsync(categoryId: Long): Category? - fun getAllCategories(): Flow> - suspend fun getAllCategoriesAsync(): List - fun getCategoriesWithMediaCount(): Flow> - fun getCategoryCount(): Flow - fun getTopCategories(limit: Int = 10): Flow> - - // Category settings - suspend fun updateCategoryThreshold(categoryId: Long, threshold: Float) - suspend fun updateCategoryName(categoryId: Long, name: String) - suspend fun toggleCategoryPinned(categoryId: Long, isPinned: Boolean) - - // Media-Category associations - fun getMediaIdsInCategory(categoryId: Long): Flow> - suspend fun getMediaIdsInCategoryAsync(categoryId: Long): List - fun getCategoriesForMedia(mediaId: Long): Flow> - suspend fun addMediaToCategory(mediaId: Long, categoryId: Long, similarity: Float = 1f, isManual: Boolean = true) - suspend fun removeMediaFromCategory(mediaId: Long, categoryId: Long) - fun getMediaCountInCategory(categoryId: Long): Flow - fun getThumbnailMediaIdForCategory(categoryId: Long): Flow - - // Category initialization and management - suspend fun initializeDefaultCategories() - suspend fun resetCategoryData() - - fun getMetadata(): Flow> - - fun getMetadata(media: Media): Flow - - suspend fun updateAlbumThumbnail(albumId: Long, thumbnail: Uri) - - suspend fun deleteAlbumThumbnail(albumId: Long) - - fun getAlbumThumbnail(albumId: Long): Flow - - fun getAlbumThumbnails(): Flow> - - fun hasAlbumThumbnail(albumId: Long): Flow - - suspend fun collectMetadataFor(media: Media) - - suspend fun addImageEmbedding(imageEmbedding: ImageEmbedding) - - suspend fun getRecord(id: Long): ImageEmbedding? - - fun getImageEmbeddings(): Flow> - - // ============ Album Groups ============ - - suspend fun insertAlbumGroup(group: AlbumGroup): Long - - suspend fun updateAlbumGroup(group: AlbumGroup) - - suspend fun deleteAlbumGroup(groupId: Long) - - fun getAllAlbumGroups(): Flow> - - fun getAlbumGroup(groupId: Long): Flow - - suspend fun getAlbumGroupAsync(groupId: Long): AlbumGroup? - - suspend fun addAlbumToGroup(member: AlbumGroupMember) - - suspend fun removeAlbumFromGroup(member: AlbumGroupMember) - - suspend fun removeAllAlbumsFromGroup(groupId: Long) - - fun getAlbumIdsInGroup(groupId: Long): Flow> - - fun getAllGroupMembers(): Flow> - - suspend fun getGroupIdForAlbum(albumId: Long): Long? - - // ============ Merged Subfolder Albums ============ - - suspend fun insertMergedSubfolderAlbum(mergedSubfolderAlbum: MergedSubfolderAlbum) - - suspend fun removeMergedSubfolderAlbum(mergedSubfolderAlbum: MergedSubfolderAlbum) - - fun getMergedSubfolderAlbums(): Flow> - - // ============ Collections ============ - - suspend fun insertCollection(collection: Collection): Long - - suspend fun updateCollection(collection: Collection) - - suspend fun deleteCollection(collectionId: Long) - - fun getCollection(collectionId: Long): Flow - - suspend fun getCollectionAsync(collectionId: Long): Collection? - - fun getAllCollections(): Flow> - - fun getCollectionsWithCount(): Flow> - - suspend fun updateCollectionLabel(collectionId: Long, label: String) - - suspend fun toggleCollectionPinned(collectionId: Long, isPinned: Boolean) - - suspend fun updateCollectionCover(collectionId: Long, mediaId: Long?) - - suspend fun addMediaToCollection(collectionId: Long, mediaId: Long) - - suspend fun addMediaListToCollection(collectionId: Long, mediaIds: List) - - suspend fun removeMediaFromCollection(collectionId: Long, mediaId: Long) - - fun getMediaIdsInCollection(collectionId: Long): Flow> - - suspend fun getMediaIdsInCollectionAsync(collectionId: Long): List - - fun getMediaCountInCollection(collectionId: Long): Flow - - fun getCollectionIdsForMedia(mediaId: Long): Flow> - - suspend fun cleanupOrphanedCollectionMedia(validMediaIds: List) - - suspend fun addAlbumsToCollection(collectionId: Long, albumIds: List) - - suspend fun removeAlbumFromCollection(collectionId: Long, albumId: Long) - - fun getAllAlbumIdsInCollections(): Flow> - - fun getAlbumIdsInCollection(collectionId: Long): Flow> - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequest.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequest.kt new file mode 100644 index 0000000000..f135b073eb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequest.kt @@ -0,0 +1,111 @@ +package com.dot.gallery.feature_node.domain.securereview + +import android.app.ComponentCaller +import android.content.ClipData +import android.content.ContentResolver +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList + +internal fun interface AuthorizeSecureReviewRequest { + operator fun invoke( + intent: Intent, + caller: ComponentCaller, + ): AuthorizedSecureReviewRequest? + + companion object { + const val MAXIMUM_URI_COUNT = 100 + } +} + +internal class AuthorizeSecureReviewRequestImpl @Inject constructor() : + AuthorizeSecureReviewRequest { + + override fun invoke( + intent: Intent, + caller: ComponentCaller, + ): AuthorizedSecureReviewRequest? { + if (intent.action != MediaStore.ACTION_REVIEW_SECURE) { + return null + } + + val distinctUris = getSuppliedUris(intent = intent)?.distinct() + + return when { + distinctUris == null -> null + distinctUris.any { uri -> !isSupportedUri(uri = uri) } -> null + distinctUris.any { uri -> !canCallerReadUri(caller = caller, uri = uri) } -> null + else -> { + AuthorizedSecureReviewRequest( + uris = distinctUris + .toImmutableList(), + ) + } + } + } + + private fun getSuppliedUris(intent: Intent): List? { + val primaryUri = intent.data + val clipData = intent.clipData + + return when { + primaryUri == null -> null + clipData == null -> listOf(primaryUri) + exceedsMaximumUriCount(clipData = clipData) -> null + + else -> { + getSecondaryUris(clipData = clipData)?.let { secondaryUris -> + listOf(primaryUri) + secondaryUris + } + } + } + } + + private fun exceedsMaximumUriCount(clipData: ClipData): Boolean { + val primaryUriCount = 1 + val suppliedUriCount = primaryUriCount + clipData.itemCount + + return suppliedUriCount > AuthorizeSecureReviewRequest.MAXIMUM_URI_COUNT + } + + private fun getSecondaryUris(clipData: ClipData): List? { + val secondaryUris = (0 until clipData.itemCount).map { index -> + getClipItemUri(item = clipData.getItemAt(index)) + } + + return when { + secondaryUris.any { uri -> uri == null } -> null + else -> secondaryUris.filterNotNull() + } + } + + private fun getClipItemUri(item: ClipData.Item): Uri? { + return item.uri?.takeIf { + item.intent == null && item.text == null && item.htmlText == null + } + } + + private fun isSupportedUri(uri: Uri): Boolean { + return uri.scheme == ContentResolver.SCHEME_CONTENT && + !uri.isOpaque && + !uri.authority.isNullOrBlank() && + uri.fragment == null + } + + private fun canCallerReadUri(caller: ComponentCaller, uri: Uri): Boolean { + return try { + caller.checkContentUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) == PackageManager.PERMISSION_GRANTED + } catch (_: IllegalArgumentException) { + false + } catch (_: SecurityException) { + false + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLocked.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLocked.kt new file mode 100644 index 0000000000..e1128fdbd4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLocked.kt @@ -0,0 +1,17 @@ +package com.dot.gallery.feature_node.domain.securereview + +import android.app.KeyguardManager +import javax.inject.Inject + +internal fun interface IsDeviceLocked { + operator fun invoke(): Boolean +} + +internal class IsDeviceLockedImpl @Inject constructor( + private val keyguardManager: KeyguardManager, +) : IsDeviceLocked { + + override operator fun invoke(): Boolean { + return keyguardManager.isDeviceLocked + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysis.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysis.kt new file mode 100644 index 0000000000..9103056048 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysis.kt @@ -0,0 +1,160 @@ +package com.dot.gallery.feature_node.domain.use_case + +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.domain.model.AiMediaAnalysisWorkState +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal data class AiMediaAnalysisSettings( + val analysisEnabled: Boolean, + val categoryClassificationEnabled: Boolean, +) + +internal interface AiMediaAnalysis { + + val settings: Flow + + val workState: Flow + + suspend fun initialize() + + suspend fun setAnalysisEnabled(enabled: Boolean) + + suspend fun setCategoryClassificationEnabled(enabled: Boolean) + + suspend fun requestAnalysis() + + suspend fun requestCategoryClassification() + + suspend fun cancelCategoryClassification() +} + +@Singleton +internal class AiMediaAnalysisImpl @Inject constructor( + private val repository: AiMediaAnalysisRepository, + private val scheduler: AiMediaAnalysisScheduler, +) : AiMediaAnalysis { + private val mutationMutex = Mutex() + private var initialized = false + + override val settings: Flow = repository.preferences.map { preferences -> + AiMediaAnalysisSettings( + analysisEnabled = preferences.analysisEnabled, + categoryClassificationEnabled = preferences.categoryClassificationEnabled, + ) + } + + override val workState: Flow = scheduler.workState + + override suspend fun initialize() { + mutationMutex.withLock { + initializeLocked() + } + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + mutationMutex.withLock { + initializeLocked() + withContext(NonCancellable) { + repository.setAnalysisEnabled(enabled = enabled) + when { + enabled -> { + scheduler.cancelGeneratedDataCleanup() + val preferences = repository.getPreferences() + if (preferences.categoryCleanupPending) { + scheduler.scheduleGeneratedDataCleanup() + } + scheduler.scheduleAnalysis( + includeCategories = preferences.categoryClassificationEnabled, + replaceExisting = true, + ) + } + + else -> { + scheduler.cancelAll() + scheduler.scheduleGeneratedDataCleanup() + } + } + } + } + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + mutationMutex.withLock { + initializeLocked() + withContext(NonCancellable) { + repository.setCategoryClassificationEnabled(enabled = enabled) + when { + enabled && repository.getPreferences().analysisEnabled -> { + scheduler.cancelGeneratedDataCleanup() + scheduler.scheduleAnalysisWithForcedCategoryClassification() + } + + !enabled -> { + cancelCategoryClassificationLocked() + scheduler.scheduleGeneratedDataCleanup() + } + } + } + } + } + + override suspend fun requestAnalysis() { + mutationMutex.withLock { + initializeLocked() + val preferences = repository.getPreferences() + if (preferences.analysisEnabled) { + scheduler.scheduleAnalysis( + includeCategories = preferences.categoryClassificationEnabled, + replaceExisting = false, + ) + } + } + } + + override suspend fun requestCategoryClassification() { + mutationMutex.withLock { + initializeLocked() + val preferences = repository.getPreferences() + if (preferences.analysisEnabled && preferences.categoryClassificationEnabled) { + scheduler.scheduleAnalysisWithForcedCategoryClassification() + } + } + } + + override suspend fun cancelCategoryClassification() { + mutationMutex.withLock { + initializeLocked() + cancelCategoryClassificationLocked() + } + } + + private suspend fun cancelCategoryClassificationLocked() { + scheduler.cancelCategoryClassification() + } + + private suspend fun initializeLocked() { + if (initialized) { + return + } + + if (repository.needsMigration()) { + repository.setAnalysisEnabled(enabled = false) + scheduler.cancelAll() + repository.completeMigration() + } + + val preferences = repository.getPreferences() + if (preferences.analysisCleanupPending || preferences.categoryCleanupPending) { + scheduler.scheduleGeneratedDataCleanup() + } + + initialized = true + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisScheduler.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisScheduler.kt new file mode 100644 index 0000000000..3e33038759 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisScheduler.kt @@ -0,0 +1,169 @@ +package com.dot.gallery.feature_node.domain.use_case + +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import com.dot.gallery.core.workers.AiMediaAnalysisCleanupWorker +import com.dot.gallery.core.workers.CategoryWorker +import com.dot.gallery.core.workers.SearchIndexerUpdaterWorker +import com.dot.gallery.feature_node.domain.model.AiMediaAnalysisWorkState +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal interface AiMediaAnalysisScheduler { + + val workState: Flow + + suspend fun scheduleAnalysis(includeCategories: Boolean, replaceExisting: Boolean) + + suspend fun scheduleAnalysisWithForcedCategoryClassification() + + suspend fun scheduleGeneratedDataCleanup() + + suspend fun cancelAll() + + suspend fun cancelCategoryClassification() + + suspend fun cancelGeneratedDataCleanup() +} + +internal class AiMediaAnalysisSchedulerImpl @Inject constructor( + private val workManager: WorkManager, +) : AiMediaAnalysisScheduler { + + override val workState: Flow = workManager + .getWorkInfosForUniqueWorkFlow(AI_MEDIA_ANALYSIS_WORK_NAME) + .map { workInfos -> workInfos.toAnalysisWorkState() } + + override suspend fun scheduleAnalysis(includeCategories: Boolean, replaceExisting: Boolean) { + enqueueAnalysis( + includeCategories = includeCategories, + forceCategoryClassification = false, + existingWorkPolicy = when { + replaceExisting -> ExistingWorkPolicy.REPLACE + else -> ExistingWorkPolicy.KEEP + }, + ) + } + + override suspend fun scheduleAnalysisWithForcedCategoryClassification() { + enqueueAnalysis( + includeCategories = true, + forceCategoryClassification = true, + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + ) + } + + override suspend fun scheduleGeneratedDataCleanup() { + val request = OneTimeWorkRequestBuilder() + .addTag(AI_MEDIA_ANALYSIS_CLEANUP_TAG) + .build() + workManager.enqueueUniqueWork( + AI_MEDIA_ANALYSIS_CLEANUP_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ).await() + } + + override suspend fun cancelAll() { + workManager.cancelUniqueWork(AI_MEDIA_ANALYSIS_WORK_NAME).await() + } + + override suspend fun cancelCategoryClassification() { + workManager.cancelAllWorkByTag(CategoryWorker.TAG).await() + } + + override suspend fun cancelGeneratedDataCleanup() { + workManager.cancelUniqueWork(AI_MEDIA_ANALYSIS_CLEANUP_WORK_NAME).await() + } + + private suspend fun enqueueAnalysis( + includeCategories: Boolean, + forceCategoryClassification: Boolean, + existingWorkPolicy: ExistingWorkPolicy, + ) { + val indexingRequest = OneTimeWorkRequestBuilder() + .setConstraints(analysisConstraints()) + .addTag(AI_MEDIA_ANALYSIS_TAG) + .addTag(SearchIndexerUpdaterWorker.TAG) + .build() + val continuation = workManager.beginUniqueWork( + AI_MEDIA_ANALYSIS_WORK_NAME, + existingWorkPolicy, + indexingRequest, + ) + + val operation = when { + includeCategories -> continuation.then( + categoryRequest(forceClassification = forceCategoryClassification), + ).enqueue() + else -> continuation.enqueue() + } + operation.await() + } + + private fun categoryRequest(forceClassification: Boolean): OneTimeWorkRequest { + return OneTimeWorkRequestBuilder() + .setConstraints(analysisConstraints()) + .setInputData( + workDataOf( + CategoryWorker.KEY_FORCE_CLASSIFICATION to forceClassification, + ), + ) + .addTag(AI_MEDIA_ANALYSIS_TAG) + .addTag(CategoryWorker.TAG) + .build() + } + + private fun analysisConstraints(): Constraints { + return Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build() + } + + private fun List.toAnalysisWorkState(): AiMediaAnalysisWorkState { + val runningIndexer = firstOrNull { workInfo -> + SearchIndexerUpdaterWorker.TAG in workInfo.tags && + workInfo.state == WorkInfo.State.RUNNING + } + val runningCategoryWorker = firstOrNull { workInfo -> + CategoryWorker.TAG in workInfo.tags && workInfo.state == WorkInfo.State.RUNNING + } + return AiMediaAnalysisWorkState( + analysisProgress = runningIndexer + ?.progress + ?.getFloat(SearchIndexerUpdaterWorker.KEY_PROGRESS, -1f) + ?.takeIf { progress -> progress >= 0f }, + isAnalysisActive = any { workInfo -> + workInfo.state == WorkInfo.State.RUNNING || + workInfo.state == WorkInfo.State.ENQUEUED + }, + categoryProgress = runningCategoryWorker + ?.progress + ?.getFloat(CategoryWorker.KEY_PROGRESS, 0f) ?: 0f, + categoryStatus = runningCategoryWorker + ?.progress + ?.getString(CategoryWorker.KEY_STATUS).orEmpty(), + isCategoryActive = any { workInfo -> + CategoryWorker.TAG in workInfo.tags && + (workInfo.state == WorkInfo.State.RUNNING || + workInfo.state == WorkInfo.State.ENQUEUED || + workInfo.state == WorkInfo.State.BLOCKED) + }, + ) + } + + companion object { + private const val AI_MEDIA_ANALYSIS_CLEANUP_TAG = "AiMediaAnalysisCleanup" + private const val AI_MEDIA_ANALYSIS_CLEANUP_WORK_NAME = "AiMediaAnalysisCleanup" + private const val AI_MEDIA_ANALYSIS_TAG = "AiMediaAnalysis" + private const val AI_MEDIA_ANALYSIS_WORK_NAME = "AiMediaAnalysis" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/UpdateMediaDatabase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/UpdateMediaDatabase.kt new file mode 100644 index 0000000000..868bdca726 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/UpdateMediaDatabase.kt @@ -0,0 +1,15 @@ +package com.dot.gallery.feature_node.domain.use_case + +import com.dot.gallery.feature_node.data.repository.MediaRepository +import javax.inject.Inject + +internal class UpdateMediaDatabase @Inject constructor( + private val repository: MediaRepository, + private val aiMediaAnalysis: AiMediaAnalysis, +) { + + suspend operator fun invoke() { + repository.updateInternalDatabase() + aiMediaAnalysis.requestAnalysis() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MotionPhotoHelper.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MotionPhotoHelper.kt deleted file mode 100644 index 62857e7630..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MotionPhotoHelper.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.dot.gallery.feature_node.domain.util - -import android.content.Context -import android.graphics.Bitmap -import android.media.MediaMetadataRetriever -import android.net.Uri -import com.dot.gallery.feature_node.presentation.util.printDebug -import com.dot.gallery.feature_node.presentation.util.printWarning -import com.drew.imaging.ImageMetadataReader -import com.drew.metadata.xmp.XmpDirectory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Describes the embedded video inside a Motion Photo / Microvideo file. - * - * @param videoOffset Byte offset from **end** of file where the MP4 stream starts. - * @param presentationTimestampUs The "favourite shot" / key-frame timestamp in µs. - * A value of -1 means the field was absent. - */ -data class MotionPhotoInfo( - val videoOffset: Long, - val presentationTimestampUs: Long = -1L -) - -object MotionPhotoHelper { - - // --- XMP property keys (Google Camera / Samsung) --------------------------------- - - // Motion Photo v2/v3 - private const val KEY_MOTION_PHOTO = "GCamera:MotionPhoto" - private const val KEY_MOTION_PHOTO_VERSION = "GCamera:MotionPhotoVersion" - private const val KEY_MOTION_PHOTO_OFFSET = "GCamera:MotionPhotoVideoOffset" - private const val KEY_MOTION_PHOTO_PTS = "GCamera:MotionPhotoPresentationTimestampUs" - - // Micro-video v1 (deprecated, still common) - private const val KEY_MICRO_VIDEO = "GCamera:MicroVideo" - private const val KEY_MICRO_VIDEO_OFFSET = "GCamera:MicroVideoOffset" - private const val KEY_MICRO_VIDEO_PTS = "GCamera:MicroVideoPresentationTimestampUs" - - // Container-based key fragments for dynamic lookup - private const val SEMANTIC_MOTION_PHOTO = "MotionPhoto" - private const val ITEM_SEMANTIC_SUFFIX = "/Item:Semantic" - private const val ITEM_LENGTH_SUFFIX = "/Item:Length" - private const val ITEM_PADDING_SUFFIX = "/Item:Padding" - - // Samsung uses a binary marker appended to the file - private val SAMSUNG_MARKER = "MotionPhoto_Data".toByteArray(Charsets.US_ASCII) - - // --------------------------------------------------------------------------------- - - /** - * Parse XMP metadata from an image [uri] and return [MotionPhotoInfo] if the file - * is a recognised Motion Photo / Microvideo. Returns `null` otherwise. - */ - fun parseInfo(context: Context, uri: Uri): MotionPhotoInfo? { - return try { - // First try XMP-based detection - val xmpResult = context.contentResolver.openInputStream(uri)?.use { stream -> - val metadata = ImageMetadataReader.readMetadata(stream) - val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) - - // Collect all XMP properties into a flat map for easier lookup - val props = mutableMapOf() - xmpDirs.forEach { dir -> - dir.xmpProperties.forEach { (k, v) -> props[k] = v } - } - - printDebug("MotionPhoto: XMP props for $uri: ${ - props.filter { it.key.contains("Camera", true) || it.key.contains("Container", true) || it.key.contains("Micro", true) || it.key.contains("Motion", true) } - }") - - // 1. Try Motion Photo v2/v3 offset - val motionPhotoFlag = props[KEY_MOTION_PHOTO] - val microVideoFlag = props[KEY_MICRO_VIDEO] - - if (motionPhotoFlag != "1" && microVideoFlag != "1") return@use null - - var videoOffset: Long? = null - var pts: Long = -1L - - // v2/v3: explicit MotionPhotoVideoOffset - props[KEY_MOTION_PHOTO_OFFSET]?.toLongOrNull()?.let { offset -> - if (offset > 0) videoOffset = offset - } - - // v2/v3: Container-based offset – find the item whose Semantic is "MotionPhoto" - if (videoOffset == null) { - // Find the key prefix for the MotionPhoto container item - val motionEntry = props.entries.firstOrNull { (k, v) -> - k.endsWith(ITEM_SEMANTIC_SUFFIX) && v == SEMANTIC_MOTION_PHOTO - } - if (motionEntry != null) { - val prefix = motionEntry.key.removeSuffix(ITEM_SEMANTIC_SUFFIX) - val length = props["$prefix$ITEM_LENGTH_SUFFIX"]?.toLongOrNull() - val padding = props["$prefix$ITEM_PADDING_SUFFIX"]?.toLongOrNull() ?: 0L - if (length != null && length > 0) { - videoOffset = length + padding - printDebug("MotionPhoto: Container item found at prefix=$prefix, length=$length, padding=$padding") - } - } - } - - // v1: MicroVideoOffset - if (videoOffset == null) { - props[KEY_MICRO_VIDEO_OFFSET]?.toLongOrNull()?.let { offset -> - if (offset > 0) videoOffset = offset - } - } - - // Presentation timestamp - props[KEY_MOTION_PHOTO_PTS]?.toLongOrNull()?.let { pts = it } - if (pts < 0) { - props[KEY_MICRO_VIDEO_PTS]?.toLongOrNull()?.let { pts = it } - } - - videoOffset?.let { MotionPhotoInfo(it, pts) } - } - - if (xmpResult != null) return xmpResult - - // Fallback: try Samsung binary marker detection - printDebug("MotionPhoto: XMP detection found nothing, trying Samsung marker for $uri") - context.contentResolver.openInputStream(uri)?.use { stream -> - val bytes = stream.readBytes() - findSamsungMarkerOffset(bytes)?.let { offset -> - printDebug("MotionPhoto: Samsung marker found at offset $offset for $uri") - MotionPhotoInfo(offset) - } - } - } catch (e: Exception) { - printWarning("MotionPhotoHelper.parseInfo failed: ${e.message}") - null - } - } - - /** - * Attempts to find the Samsung MotionPhoto_Data marker inside the file - * and returns the offset of the video data after the marker. - */ - private fun findSamsungMarkerOffset(bytes: ByteArray): Long? { - val marker = SAMSUNG_MARKER - val markerLen = marker.size - if (bytes.size < markerLen + 4) return null - - // Search backwards from end for better perf (marker is near EOF) - var i = bytes.size - markerLen - while (i >= 0) { - if (bytes[i] == marker[0]) { - var match = true - for (j in 1 until markerLen) { - if (bytes[i + j] != marker[j]) { match = false; break } - } - if (match) { - val videoStart = i + markerLen - return (bytes.size - videoStart).toLong() - } - } - i-- - } - return null - } - - /** - * Extract the embedded MP4 video from a Motion Photo file and write it to - * a temporary file in [context]'s cache directory. - * - * @return The temp [File] containing the MP4 video, or `null` on failure. - */ - suspend fun extractVideo( - context: Context, - uri: Uri, - info: MotionPhotoInfo - ): File? = withContext(Dispatchers.IO) { - try { - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: return@withContext null - - var offset = info.videoOffset - - // Sanity-check: if offset is bigger than file, try Samsung marker fallback - if (offset <= 0 || offset > bytes.size) { - offset = findSamsungMarkerOffset(bytes) ?: return@withContext null - } - - val videoStart = (bytes.size - offset).toInt() - if (videoStart < 0 || videoStart >= bytes.size) { - printWarning("MotionPhoto: invalid video start $videoStart for file size ${bytes.size}") - return@withContext null - } - - val videoBytes = bytes.copyOfRange(videoStart, bytes.size) - - // Quick sanity: MP4 files start with 'ftyp' box (at byte 4) - if (videoBytes.size > 8) { - val ftyp = String(videoBytes, 4, 4, Charsets.US_ASCII) - if (ftyp != "ftyp") { - printWarning("MotionPhoto: extracted data does not start with ftyp box (got '$ftyp'), trying Samsung marker fallback") - // Fallback to Samsung marker - val samsungOffset = findSamsungMarkerOffset(bytes) - if (samsungOffset != null && samsungOffset != offset) { - val samsungStart = (bytes.size - samsungOffset).toInt() - if (samsungStart in 0 until bytes.size) { - val samsungVideoBytes = bytes.copyOfRange(samsungStart, bytes.size) - if (samsungVideoBytes.size > 8) { - val samsungFtyp = String(samsungVideoBytes, 4, 4, Charsets.US_ASCII) - if (samsungFtyp == "ftyp") { - val tmpFile = File(context.cacheDir, "motion_photo_${System.currentTimeMillis()}.mp4") - tmpFile.writeBytes(samsungVideoBytes) - printDebug("MotionPhoto: extracted ${samsungVideoBytes.size} bytes (Samsung fallback) to ${tmpFile.absolutePath}") - return@withContext tmpFile - } - } - } - } - // Still no luck, write what we have - } - } - - val tmpFile = File(context.cacheDir, "motion_photo_${System.currentTimeMillis()}.mp4") - tmpFile.writeBytes(videoBytes) - printDebug("MotionPhoto: extracted ${videoBytes.size} bytes to ${tmpFile.absolutePath}") - tmpFile - } catch (e: Exception) { - printWarning("MotionPhoto: extraction failed: ${e.message}") - null - } - } - - /** - * Extract [numFrames] evenly-spaced thumbnail frames from the given video [file]. - * Returns the list of bitmaps (may be fewer than requested if extraction fails - * for some timestamps). - */ - suspend fun extractFrames( - file: File, - numFrames: Int = 10 - ): List = withContext(Dispatchers.IO) { - val frames = mutableListOf() - val retriever = MediaMetadataRetriever() - try { - retriever.setDataSource(file.absolutePath) - val durationUs = (retriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_DURATION - )?.toLongOrNull() ?: 0L) * 1000L // ms → µs - - if (durationUs <= 0 || numFrames <= 0) return@withContext frames - - val intervalUs = durationUs / numFrames - for (i in 0 until numFrames) { - val timeUs = i * intervalUs + intervalUs / 2 - val bitmap = retriever.getFrameAtTime( - timeUs, - MediaMetadataRetriever.OPTION_CLOSEST_SYNC - ) - if (bitmap != null) { - frames.add(bitmap) - } - } - } catch (e: Exception) { - printWarning("MotionPhoto: frame extraction failed: ${e.message}") - } finally { - try { retriever.release() } catch (_: Exception) {} - } - frames - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt deleted file mode 100644 index ea0055abc2..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.util - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable - -@Serializable -@Parcelize -sealed class OrderType : Parcelable { - @Serializable - @Parcelize - data object Ascending : OrderType() - - @Serializable - @Parcelize - data object Descending : OrderType() -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumGroupViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumGroupViewScreen.kt index f57212c0c0..52240fc536 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumGroupViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumGroupViewScreen.kt @@ -7,10 +7,10 @@ package com.dot.gallery.feature_node.presentation.albums import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.AnimatedContentScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -41,7 +41,7 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.util.mediaSharedElement diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt index 89903c2ab6..d2a01d6656 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt @@ -39,8 +39,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation @@ -56,11 +54,11 @@ import com.dot.gallery.core.presentation.components.FilterButton import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.presentation.components.FilterOption import com.dot.gallery.core.presentation.components.LoadingAlbum -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.util.MediaOrder import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums -import com.dot.gallery.feature_node.domain.model.CollectionWithCount import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupComponent import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupRowComponent @@ -70,6 +68,8 @@ import com.dot.gallery.feature_node.presentation.collection.components.Collectio import com.dot.gallery.feature_node.presentation.collection.components.CollectionRowComponent import com.dot.gallery.feature_node.presentation.collection.components.CreateCollectionComponent import com.dot.gallery.feature_node.presentation.collection.components.CreateCollectionRowComponent +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.timeline.components.TimelineNavActions import com.dot.gallery.feature_node.presentation.util.LocalHazeState @@ -281,12 +281,18 @@ fun AlbumsScreen( item( key = "createCollection" ) { - CreateCollectionComponent( - modifier = Modifier - .pinchItem(key = "createCollection") - .animateItem(), - onClick = onCreateCollection - ) + AnimatedVisibility( + visible = albumsState.value.albums.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + CreateCollectionComponent( + modifier = Modifier + .pinchItem(key = "createCollection") + .animateItem(), + onClick = onCreateCollection + ) + } } item( @@ -459,10 +465,16 @@ fun AlbumsScreen( ) } item("createCollection_list") { - CreateCollectionRowComponent( - modifier = Modifier.animateItem(), - onClick = onCreateCollection - ) + AnimatedVisibility( + visible = albumsState.value.albums.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + CreateCollectionRowComponent( + modifier = Modifier.animateItem(), + onClick = onCreateCollection + ) + } } item(key = "albumDetails") { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt index 25d6372356..3dd101edcb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt @@ -13,26 +13,26 @@ import com.dot.gallery.R import com.dot.gallery.core.Settings import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.presentation.components.FilterOption -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.AlbumGroup -import com.dot.gallery.feature_node.domain.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.AlbumGroup +import com.dot.gallery.feature_node.data.model.AlbumGroupMember +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.OrderType import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType import com.dot.gallery.feature_node.presentation.util.Screen import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class AlbumsViewModel @Inject constructor( @@ -189,7 +189,7 @@ class AlbumsViewModel @Inject constructor( fun createCollection(name: String) { viewModelScope.launch(Dispatchers.IO) { repository.insertCollection( - com.dot.gallery.feature_node.domain.model.Collection(label = name) + com.dot.gallery.feature_node.data.model.Collection(label = name) ) } } @@ -215,7 +215,7 @@ class AlbumsViewModel @Inject constructor( fun createCollectionWithAlbums(name: String, albumIds: List) { viewModelScope.launch(Dispatchers.IO) { val collectionId = repository.insertCollection( - com.dot.gallery.feature_node.domain.model.Collection(label = name) + com.dot.gallery.feature_node.data.model.Collection(label = name) ) repository.addAlbumsToCollection(collectionId, albumIds) for (albumId in albumIds) { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/EditGroupScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/EditGroupScreen.kt index 085abb28fc..18eba5053d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/EditGroupScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/EditGroupScreen.kt @@ -57,10 +57,11 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.dot.gallery.R import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -256,7 +257,7 @@ private fun EditGroupAlbumItem( onClick() } ), - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, requestBuilderTransform = { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt index 87ad0f01df..9dbad8699f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt @@ -70,7 +70,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.presentation.components.LocalMediaImageRenderer -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionLayoutStyle import com.dot.gallery.feature_node.presentation.common.components.OptionSheet @@ -80,6 +80,7 @@ import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.printError import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.Shapes import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -512,7 +513,7 @@ fun AlbumOptionSheet( .size(98.dp) .clip(Shapes.large), contentScale = ContentScale.Crop, - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, signature = album ) @@ -539,8 +540,9 @@ fun AlbumOptionSheet( ) ) { append( - stringResource( - R.string.s_items, + pluralStringResource( + id = R.plurals.item_count, + count = album.count.toInt(), album.count ) + " (${formatSize(album.size)})" ) @@ -653,10 +655,10 @@ fun AlbumImage( } } ), - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, signature = album ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumGroupComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumGroupComponent.kt index 7a1619c134..4d7420159e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumGroupComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumGroupComponent.kt @@ -52,6 +52,7 @@ import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalGlideComposeApi::class) @@ -543,7 +544,7 @@ fun AlbumGroupRowComponent( @Composable internal fun GroupThumbnailCell( modifier: Modifier = Modifier, - album: com.dot.gallery.feature_node.domain.model.Album?, + album: com.dot.gallery.feature_node.data.model.Album?, cornerShape: RoundedCornerShape ) { if (album != null) { @@ -551,7 +552,7 @@ internal fun GroupThumbnailCell( modifier = modifier .fillMaxSize() .clip(cornerShape), - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, requestBuilderTransform = { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt index 8e327fb8ca..2cd7d71f94 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -46,16 +47,17 @@ import com.bumptech.glide.Glide import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.Shapes import com.google.android.material.carousel.CarouselLayoutManager import com.google.android.material.carousel.MaskableFrameLayout -import kotlinx.coroutines.launch import kotlin.math.roundToInt +import kotlinx.coroutines.launch @OptIn(ExperimentalGlideComposeApi::class) @Composable @@ -141,7 +143,7 @@ fun CarouselPinnedAlbums( .size(98.dp) .clip(Shapes.large), contentScale = ContentScale.Crop, - model = currentAlbum!!.uri, + model = currentAlbum!!.toGlideModel(), contentDescription = currentAlbum!!.label, requestBuilderTransform = { it.signature(GlideInvalidation.signature(currentAlbum!!)) @@ -168,7 +170,13 @@ fun CarouselPinnedAlbums( letterSpacing = MaterialTheme.typography.bodyMedium.letterSpacing ) ) { - append(stringResource(R.string.s_items, currentAlbum!!.count)) + append( + pluralStringResource( + id = R.plurals.item_count, + count = currentAlbum!!.count.toInt(), + currentAlbum!!.count + ) + ) } }, textAlign = TextAlign.Center, @@ -204,7 +212,7 @@ private class PinnedAlbumsAdapter( intArrayOf(containerColor, Color.TRANSPARENT) ) Glide.with(albumImage) - .load(album.uri) + .load(album.toGlideModel()) .centerCrop() .into(albumImage) albumImage.isClickable = true @@ -272,4 +280,4 @@ private object PinnedAlbumsDiffCallback : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album): Boolean { return oldItem.id == newItem.id } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/AlbumTimelineScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/AlbumTimelineScreen.kt index 31118d01bd..8cd5f2fe7c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/AlbumTimelineScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/AlbumTimelineScreen.kt @@ -66,15 +66,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.core.Settings.Album.rememberAlbumMediaSort import com.dot.gallery.core.Settings +import com.dot.gallery.core.Settings.Album.rememberAlbumMediaSort import com.dot.gallery.core.Settings.Album.rememberHideTimelineOnAlbum import com.dot.gallery.core.Settings.Misc.rememberGridSize import com.dot.gallery.core.Settings.Misc.rememberMosaicGridSize @@ -83,21 +81,24 @@ import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationButton import com.dot.gallery.core.presentation.components.SelectionSheet -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.albumtimeline.components.AlbumSortDropdown +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.MosaicMediaGrid import com.dot.gallery.feature_node.presentation.common.components.MosaicPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.TimelineScroller -import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState +import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.selectedMedia +import com.dot.gallery.feature_node.presentation.util.toGlideModel import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -403,7 +404,7 @@ private fun AlbumsMergedBanner( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(12.dp)), - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -426,4 +427,4 @@ private fun AlbumsMergedBanner( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/components/AlbumSortDropdown.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/components/AlbumSortDropdown.kt index 33957532b3..eb5ef4b035 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/components/AlbumSortDropdown.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albumtimeline/components/AlbumSortDropdown.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.Settings.Album.LastSort import com.dot.gallery.core.presentation.components.FilterKind -import com.dot.gallery.feature_node.domain.util.OrderType +import com.dot.gallery.feature_node.data.util.OrderType @Composable fun AlbumSortDropdown( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastModule.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastModule.kt deleted file mode 100644 index a3efbda8f3..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast - -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -/** - * FCastSession is provided as a @Singleton by constructor injection. - * This module exists to ensure the cast package is included in Hilt's component graph. - */ -@Module -@InstallIn(SingletonComponent::class) -object FCastModule diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastProtocol.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastProtocol.kt deleted file mode 100644 index a519f43573..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastProtocol.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.io.InputStream -import java.io.OutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder - -/** - * FCast protocol implementation (v1–v2). - * TCP on port 46899. Header: uint32 size (LE) + uint8 opcode. Body: UTF-8 JSON. - */ -object FCastProtocol { - const val DEFAULT_PORT = 46899 - const val SERVICE_TYPE = "_fcast._tcp." - const val MAX_PACKET_SIZE = 32_000 - - val json = Json { - ignoreUnknownKeys = true - encodeDefaults = false - explicitNulls = false - } -} - -enum class Opcode(val value: Int) { - PLAY(1), - PAUSE(2), - RESUME(3), - STOP(4), - SEEK(5), - PLAYBACK_UPDATE(6), - VOLUME_UPDATE(7), - SET_VOLUME(8), - PLAYBACK_ERROR(9), - SET_SPEED(10), - VERSION(11), - PING(12), - PONG(13); - - companion object { - fun fromValue(value: Int): Opcode? = entries.find { it.value == value } - } -} - -enum class PlaybackState(val value: Int) { - IDLE(0), - PLAYING(1), - PAUSED(2); - - companion object { - fun fromValue(value: Int): PlaybackState = entries.find { it.value == value } ?: IDLE - } -} - -// --- Sender messages --- - -@Serializable -data class PlayMessage( - val container: String, - val url: String? = null, - val content: String? = null, - val time: Double? = null, - val volume: Double? = null, - val speed: Double? = null, - val headers: Map? = null -) - -@Serializable -data class SeekMessage( - val time: Double -) - -@Serializable -data class SetVolumeMessage( - val volume: Double -) - -@Serializable -data class SetSpeedMessage( - val speed: Double -) - -@Serializable -data class VersionMessage( - val version: Int -) - -// --- Receiver messages --- - -@Serializable -data class PlaybackUpdateMessage( - val generationTime: Long? = null, - val state: Int, - val time: Double? = null, - val duration: Double? = null, - val speed: Double? = null -) - -@Serializable -data class VolumeUpdateMessage( - val generationTime: Long? = null, - val volume: Double -) - -@Serializable -data class PlaybackErrorMessage( - val message: String -) - -// --- Packet I/O --- - -fun OutputStream.sendPacket(opcode: Opcode, body: String? = null) { - val bodyBytes = body?.toByteArray(Charsets.UTF_8) - val size = 1 + (bodyBytes?.size ?: 0) // opcode byte + body - val header = ByteBuffer.allocate(5) - .order(ByteOrder.LITTLE_ENDIAN) - .putInt(size) - .put(opcode.value.toByte()) - .array() - write(header) - bodyBytes?.let { write(it) } - flush() -} - -data class ReceivedPacket(val opcode: Opcode, val body: String?) - -fun InputStream.readPacket(): ReceivedPacket? { - val headerBuf = ByteArray(5) - var read = 0 - while (read < 5) { - val n = read(headerBuf, read, 5 - read) - if (n == -1) return null - read += n - } - val size = ByteBuffer.wrap(headerBuf, 0, 4) - .order(ByteOrder.LITTLE_ENDIAN) - .int - val opcodeVal = headerBuf[4].toInt() and 0xFF - val opcode = Opcode.fromValue(opcodeVal) ?: return null - - val bodySize = size - 1 - val body = if (bodySize > 0) { - val bodyBuf = ByteArray(bodySize.coerceAtMost(FCastProtocol.MAX_PACKET_SIZE)) - var bodyRead = 0 - while (bodyRead < bodySize) { - val n = read(bodyBuf, bodyRead, bodySize - bodyRead) - if (n == -1) break - bodyRead += n - } - String(bodyBuf, 0, bodyRead, Charsets.UTF_8) - } else null - - return ReceivedPacket(opcode, body) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastSession.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastSession.kt deleted file mode 100644 index d0861bb16c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastSession.kt +++ /dev/null @@ -1,676 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.net.Uri -import android.net.wifi.WifiManager -import android.os.Build -import androidx.core.content.ContextCompat -import java.net.Inet4Address -import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.presentation.util.printDebug -import com.dot.gallery.feature_node.presentation.util.printWarning -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream -import java.net.InetSocketAddress -import java.net.Socket -import javax.inject.Inject -import javax.inject.Singleton - -data class FCastDevice( - val name: String, - val host: String, - val port: Int -) - -data class RemotePlaybackState( - val state: PlaybackState = PlaybackState.IDLE, - val timeSeconds: Double = 0.0, - val durationSeconds: Double = 0.0, - val speed: Double = 1.0, - val volume: Double = 1.0, - val error: String? = null -) - -data class CastPermission( - val permission: String, - val labelRes: Int, - val descriptionRes: Int, - val granted: Boolean -) - -data class CastSessionState( - val isDiscovering: Boolean = false, - val discoveredDevices: List = emptyList(), - val connectedDevice: FCastDevice? = null, - val isConnecting: Boolean = false, - val connectionError: String? = null, - val playback: RemotePlaybackState = RemotePlaybackState(), - val castingMediaId: Long? = null -) - -/** - * Singleton service managing FCast device discovery, TCP connection, and media casting. - */ -@Singleton -class FCastSession @Inject constructor( - @param:ApplicationContext private val context: Context -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val _state = MutableStateFlow(CastSessionState()) - val state: StateFlow = _state.asStateFlow() - - private var nsdManager: NsdManager? = null - private var discoveryListener: NsdManager.DiscoveryListener? = null - private var multicastLock: WifiManager.MulticastLock? = null - - private var socket: Socket? = null - private var outputStream: OutputStream? = null - private var inputStream: InputStream? = null - private var receiverJob: Job? = null - - private var httpServer: LocalMediaHttpServer? = null - - // --- Permission checks --- - - /** - * Returns true if the cast feature is available (i.e. the required network - * permissions are declared in the manifest). When permissions are stripped - * at build time the feature cannot work, so the UI should be hidden. - */ - fun isCastAvailable(): Boolean { - val requestedPerms = try { - context.packageManager - .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) - .requestedPermissions - } catch (_: Exception) { null } - return requestedPerms?.contains(Manifest.permission.INTERNET) == true - } - - private fun hasPermission(permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - - /** - * Returns a list of all required cast permissions with their current grant status. - */ - fun checkPermissions(): List = listOf( - CastPermission( - permission = Manifest.permission.INTERNET, - labelRes = R.string.cast_perm_internet, - descriptionRes = R.string.cast_perm_internet_desc, - granted = hasPermission(Manifest.permission.INTERNET) - ), - CastPermission( - permission = Manifest.permission.ACCESS_WIFI_STATE, - labelRes = R.string.cast_perm_wifi_state, - descriptionRes = R.string.cast_perm_wifi_state_desc, - granted = hasPermission(Manifest.permission.ACCESS_WIFI_STATE) - ), - CastPermission( - permission = Manifest.permission.ACCESS_NETWORK_STATE, - labelRes = R.string.cast_perm_network_state, - descriptionRes = R.string.cast_perm_network_state_desc, - granted = hasPermission(Manifest.permission.ACCESS_NETWORK_STATE) - ), - CastPermission( - permission = Manifest.permission.CHANGE_WIFI_MULTICAST_STATE, - labelRes = R.string.cast_perm_multicast, - descriptionRes = R.string.cast_perm_multicast_desc, - granted = hasPermission(Manifest.permission.CHANGE_WIFI_MULTICAST_STATE) - ), - ) - - /** - * Returns true if all required permissions are granted. - */ - fun hasAllPermissions(): Boolean = checkPermissions().all { it.granted } - - private fun checkNetworkPermissions(): String? { - if (!hasPermission(Manifest.permission.INTERNET)) - return "Internet permission is required for casting" - return null - } - - // --- Discovery --- - - fun startDiscovery() { - if (_state.value.isDiscovering) return - _state.update { it.copy(isDiscovering = true, discoveredDevices = emptyList(), connectionError = null) } - - // Acquire multicast lock so Android doesn't filter out mDNS packets - try { - val wifiManager = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - multicastLock = wifiManager.createMulticastLock("fcast_discovery").apply { - setReferenceCounted(true) - acquire() - } - printDebug("FCast: Multicast lock acquired") - } catch (e: SecurityException) { - printWarning("FCast: Multicast permission denied: ${e.message}") - // Discovery can still work for manual IP, just won't find mDNS devices - } catch (e: Exception) { - printWarning("FCast: Failed to acquire multicast lock: ${e.message}") - } - - val manager = try { - context.getSystemService(Context.NSD_SERVICE) as NsdManager - } catch (e: Exception) { - printWarning("FCast: NSD service unavailable: ${e.message}") - _state.update { it.copy(isDiscovering = false) } - return - } - nsdManager = manager - - discoveryListener = object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(serviceType: String) { - printDebug("FCast: Discovery started") - } - - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - printDebug("FCast: Found service: ${serviceInfo.serviceName}") - resolveServiceCompat(manager, serviceInfo) - } - - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - _state.update { st -> - st.copy(discoveredDevices = st.discoveredDevices.filter { - it.name != serviceInfo.serviceName - }) - } - } - - override fun onDiscoveryStopped(serviceType: String) { - printDebug("FCast: Discovery stopped") - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - printWarning("FCast: Start discovery failed: $errorCode") - _state.update { it.copy(isDiscovering = false) } - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - printWarning("FCast: Stop discovery failed: $errorCode") - } - } - - try { - manager.discoverServices( - FCastProtocol.SERVICE_TYPE, - NsdManager.PROTOCOL_DNS_SD, - discoveryListener - ) - } catch (e: Exception) { - printWarning("FCast: Failed to start NSD discovery: ${e.message}") - _state.update { it.copy(isDiscovering = false, connectionError = e.message) } - } - } - - fun stopDiscovery() { - discoveryListener?.let { listener -> - try { - nsdManager?.stopServiceDiscovery(listener) - } catch (_: Exception) { } - } - discoveryListener = null - try { - if (multicastLock?.isHeld == true) { - multicastLock?.release() - printDebug("FCast: Multicast lock released") - } - } catch (_: Exception) { } - multicastLock = null - _state.update { it.copy(isDiscovering = false) } - } - - // --- Service resolution (version-gated) --- - - private fun resolveServiceCompat(manager: NsdManager, serviceInfo: NsdServiceInfo) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+ - resolveServiceModern(manager, serviceInfo) - } else { - resolveServiceLegacy(manager, serviceInfo) - } - } - - @android.annotation.TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - private fun resolveServiceModern(manager: NsdManager, serviceInfo: NsdServiceInfo) { - val callback = object : NsdManager.ServiceInfoCallback { - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - printWarning("FCast: ServiceInfoCallback registration failed: $errorCode") - } - - override fun onServiceUpdated(si: NsdServiceInfo) { - val host = si.hostAddresses - .filterIsInstance() - .firstOrNull()?.hostAddress ?: return - onServiceResolved(si.serviceName, host, si.port) - manager.unregisterServiceInfoCallback(this) - } - - override fun onServiceLost() { - printDebug("FCast: Service lost during resolve") - } - - override fun onServiceInfoCallbackUnregistered() { } - } - manager.registerServiceInfoCallback(serviceInfo, { it.run() }, callback) - } - - private fun resolveServiceLegacy(manager: NsdManager, serviceInfo: NsdServiceInfo) { - @Suppress("DEPRECATION") - manager.resolveService(serviceInfo, object : NsdManager.ResolveListener { - override fun onResolveFailed(si: NsdServiceInfo, errorCode: Int) { - printWarning("FCast: Resolve failed for ${si.serviceName}: $errorCode") - } - - override fun onServiceResolved(si: NsdServiceInfo) { - @Suppress("DEPRECATION") - val host = si.host?.hostAddress ?: return - onServiceResolved(si.serviceName, host, si.port) - } - }) - } - - private fun onServiceResolved(name: String, host: String, port: Int) { - val device = FCastDevice(name = name, host = host, port = port) - printDebug("FCast: Resolved ${device.name} at ${device.host}:${device.port}") - _state.update { st -> - val devices = st.discoveredDevices.toMutableList() - if (devices.none { it.host == device.host && it.port == device.port }) { - devices.add(device) - } - st.copy(discoveredDevices = devices) - } - } - - // --- Connection --- - - fun connect(device: FCastDevice) { - if (_state.value.connectedDevice != null) disconnect() - - // Check network permissions before attempting connection - checkNetworkPermissions()?.let { error -> - _state.update { - it.copy(isConnecting = false, connectionError = error) - } - return - } - - _state.update { - it.copy(isConnecting = true, connectionError = null) - } - - scope.launch { - try { - val sock = Socket() - sock.connect(InetSocketAddress(device.host, device.port), 5000) - sock.soTimeout = 0 - - socket = sock - outputStream = sock.getOutputStream() - inputStream = sock.getInputStream() - - // Send version handshake (v2) - val versionBody = FCastProtocol.json.encodeToString( - VersionMessage.serializer(), - VersionMessage(version = 2) - ) - outputStream?.sendPacket(Opcode.VERSION, versionBody) - - _state.update { - it.copy( - connectedDevice = device, - isConnecting = false, - connectionError = null - ) - } - - startReceiver() - startHttpServer() - - printDebug("FCast: Connected to ${device.name}") - } catch (e: SecurityException) { - printWarning("FCast: Permission denied: ${e.message}") - _state.update { - it.copy( - isConnecting = false, - connectionError = "Network permission denied" - ) - } - cleanupConnection() - } catch (e: Exception) { - printWarning("FCast: Connection failed: ${e.message}") - _state.update { - it.copy( - isConnecting = false, - connectionError = e.message ?: "Connection failed" - ) - } - cleanupConnection() - } - } - } - - fun disconnect() { - scope.launch { - try { - outputStream?.sendPacket(Opcode.STOP) - } catch (_: Exception) { } - cleanupConnection() - stopHttpServer() - _state.update { - CastSessionState( - isDiscovering = it.isDiscovering, - discoveredDevices = it.discoveredDevices - ) - } - printDebug("FCast: Disconnected") - } - } - - private fun cleanupConnection() { - receiverJob?.cancel() - receiverJob = null - try { inputStream?.close() } catch (_: Exception) { } - try { outputStream?.close() } catch (_: Exception) { } - try { socket?.close() } catch (_: Exception) { } - socket = null - outputStream = null - inputStream = null - } - - private fun startReceiver() { - receiverJob?.cancel() - receiverJob = scope.launch { - val input = inputStream ?: return@launch - try { - while (isActive) { - val packet = input.readPacket() ?: break - handleReceiverPacket(packet) - } - } catch (_: Exception) { - // Socket closed or read error - } - // Connection lost - if (_state.value.connectedDevice != null) { - _state.update { - it.copy( - connectedDevice = null, - connectionError = "Connection lost", - castingMediaId = null - ) - } - } - } - } - - private fun handleReceiverPacket(packet: ReceivedPacket) { - when (packet.opcode) { - Opcode.PLAYBACK_UPDATE -> { - packet.body?.let { body -> - try { - val update = FCastProtocol.json.decodeFromString( - PlaybackUpdateMessage.serializer(), body - ) - _state.update { st -> - st.copy( - playback = st.playback.copy( - state = PlaybackState.fromValue(update.state), - timeSeconds = update.time ?: st.playback.timeSeconds, - durationSeconds = update.duration ?: st.playback.durationSeconds, - speed = update.speed ?: st.playback.speed, - error = null - ) - ) - } - } catch (_: Exception) { } - } - } - Opcode.VOLUME_UPDATE -> { - packet.body?.let { body -> - try { - val update = FCastProtocol.json.decodeFromString( - VolumeUpdateMessage.serializer(), body - ) - _state.update { st -> - st.copy( - playback = st.playback.copy(volume = update.volume) - ) - } - } catch (_: Exception) { } - } - } - Opcode.PLAYBACK_ERROR -> { - packet.body?.let { body -> - try { - val error = FCastProtocol.json.decodeFromString( - PlaybackErrorMessage.serializer(), body - ) - _state.update { st -> - st.copy( - playback = st.playback.copy( - state = PlaybackState.IDLE, - error = error.message - ) - ) - } - } catch (_: Exception) { } - } - } - Opcode.PING -> { - scope.launch { - try { - outputStream?.sendPacket(Opcode.PONG) - } catch (_: Exception) { } - } - } - Opcode.VERSION -> { - // Receiver version acknowledgment, ignore for now - } - else -> { - printDebug("FCast: Unhandled opcode: ${packet.opcode}") - } - } - } - - // --- HTTP Server --- - - private fun startHttpServer() { - if (httpServer != null) return - try { - httpServer = LocalMediaHttpServer(context).also { - it.start() - printDebug("FCast: HTTP server started on port ${it.listeningPort}") - } - } catch (e: Exception) { - printWarning("FCast: Failed to start HTTP server: ${e.message}") - _state.update { - it.copy(connectionError = "Failed to start media server: ${e.message}") - } - } - } - - private fun stopHttpServer() { - httpServer?.stop() - httpServer = null - } - - // --- Casting commands --- - - fun castMedia(media: T) { - val server = httpServer ?: return - val out = outputStream ?: return - - scope.launch { - try { - val token = media.id.toString() - server.registerUri( - token = token, - uri = media.getUri(), - mimeType = media.mimeType, - size = media.size - ) - - val url = server.getMediaUrl(token) - ?: throw IllegalStateException("Cannot determine LAN URL") - - val playMsg = PlayMessage( - container = media.mimeType, - url = url, - time = 0.0, - speed = 1.0 - ) - val body = FCastProtocol.json.encodeToString( - PlayMessage.serializer(), playMsg - ) - out.sendPacket(Opcode.PLAY, body) - - _state.update { - it.copy( - castingMediaId = media.id, - playback = RemotePlaybackState(state = PlaybackState.PLAYING) - ) - } - printDebug("FCast: Casting ${media.label} → $url") - } catch (e: Exception) { - printWarning("FCast: Cast failed: ${e.message}") - } - } - } - - fun castFile(file: java.io.File, mimeType: String, label: String, mediaId: Long) { - val server = httpServer ?: return - val out = outputStream ?: return - - scope.launch { - try { - val token = mediaId.toString() - server.registerFile(token = token, file = file, mimeType = mimeType) - - val url = server.getMediaUrl(token) - ?: throw IllegalStateException("Cannot determine LAN URL") - - val playMsg = PlayMessage( - container = mimeType, - url = url, - time = 0.0, - speed = 1.0 - ) - val body = FCastProtocol.json.encodeToString( - PlayMessage.serializer(), playMsg - ) - out.sendPacket(Opcode.PLAY, body) - - _state.update { - it.copy( - castingMediaId = mediaId, - playback = RemotePlaybackState(state = PlaybackState.PLAYING) - ) - } - printDebug("FCast: Casting $label → $url") - } catch (e: Exception) { - printWarning("FCast: Cast file failed: ${e.message}") - } - } - } - - fun pause() { - scope.launch { - try { - outputStream?.sendPacket(Opcode.PAUSE) - } catch (_: Exception) { } - } - } - - fun resume() { - scope.launch { - try { - outputStream?.sendPacket(Opcode.RESUME) - } catch (_: Exception) { } - } - } - - fun togglePlayPause() { - if (_state.value.playback.state == PlaybackState.PLAYING) pause() else resume() - } - - fun seek(timeSeconds: Double) { - scope.launch { - try { - val body = FCastProtocol.json.encodeToString( - SeekMessage.serializer(), SeekMessage(time = timeSeconds) - ) - outputStream?.sendPacket(Opcode.SEEK, body) - } catch (_: Exception) { } - } - } - - fun setVolume(volume: Double) { - scope.launch { - try { - val body = FCastProtocol.json.encodeToString( - SetVolumeMessage.serializer(), SetVolumeMessage(volume = volume.coerceIn(0.0, 1.0)) - ) - outputStream?.sendPacket(Opcode.SET_VOLUME, body) - } catch (_: Exception) { } - } - } - - fun setSpeed(speed: Double) { - scope.launch { - try { - val body = FCastProtocol.json.encodeToString( - SetSpeedMessage.serializer(), SetSpeedMessage(speed = speed) - ) - outputStream?.sendPacket(Opcode.SET_SPEED, body) - } catch (_: Exception) { } - } - } - - fun stop() { - scope.launch { - try { - outputStream?.sendPacket(Opcode.STOP) - httpServer?.unregisterAll() - _state.update { - it.copy( - castingMediaId = null, - playback = RemotePlaybackState() - ) - } - } catch (_: Exception) { } - } - } - - val isConnected: Boolean - get() = _state.value.connectedDevice != null - - val isCasting: Boolean - get() = _state.value.castingMediaId != null -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastViewModel.kt deleted file mode 100644 index 69a0107c5c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/FCastViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast - -import android.content.Context -import androidx.lifecycle.ViewModel -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.StateFlow -import java.io.File -import java.io.FileOutputStream -import javax.inject.Inject - -@HiltViewModel -class FCastViewModel @Inject constructor( - @param:ApplicationContext private val appContext: Context, - private val session: FCastSession -) : ViewModel() { - - val state: StateFlow = session.state - - val isConnected: Boolean get() = session.isConnected - val isCasting: Boolean get() = session.isCasting - - fun isCastAvailable(): Boolean = session.isCastAvailable() - fun checkPermissions(): List = session.checkPermissions() - fun hasAllPermissions(): Boolean = session.hasAllPermissions() - - fun startDiscovery() = session.startDiscovery() - fun stopDiscovery() = session.stopDiscovery() - - fun connect(device: FCastDevice) { - session.stopDiscovery() - session.connect(device) - } - - fun disconnect() = session.disconnect() - - fun castMedia(media: T) { - if (media.isEncrypted) { - castEncryptedMedia(media) - } else { - session.castMedia(media) - } - } - - private fun castEncryptedMedia(media: T) { - val keychainHolder = KeychainHolder(appContext) - try { - val encryptedFile = File(media.getUri().path!!) - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - val tempFile = File.createTempFile("fcast_${media.id}", null, appContext.cacheDir) - decrypted.openStream().use { input -> - FileOutputStream(tempFile).use { out -> - input.copyTo(out) - } - } - session.castFile( - file = tempFile, - mimeType = media.mimeType, - label = media.label, - mediaId = media.id - ) - } catch (e: Exception) { - // Fallback: try as regular media - session.castMedia(media) - } - } - - fun togglePlayPause() = session.togglePlayPause() - fun pause() = session.pause() - fun resume() = session.resume() - fun seek(timeSeconds: Double) = session.seek(timeSeconds) - fun setVolume(volume: Double) = session.setVolume(volume) - fun setSpeed(speed: Double) = session.setSpeed(speed) - fun stopCasting() = session.stop() -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/LocalMediaHttpServer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/LocalMediaHttpServer.kt deleted file mode 100644 index ad54d2ad71..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/LocalMediaHttpServer.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast - -import android.content.Context -import android.net.Uri -import fi.iki.elonen.NanoHTTPD -import java.io.File -import java.io.FileInputStream -import java.io.InputStream -import java.net.Inet4Address -import java.net.NetworkInterface -import java.util.concurrent.ConcurrentHashMap - -/** - * Lightweight HTTP server that serves local media files to FCast receivers on the LAN. - * Each media item is registered with a unique token and served at /media/{token}. - */ -class LocalMediaHttpServer( - private val context: Context, - port: Int = 0 // 0 = auto-assign -) : NanoHTTPD(port) { - - private data class MediaEntry( - val uri: Uri?, - val file: File?, - val mimeType: String, - val size: Long - ) - - private val mediaEntries = ConcurrentHashMap() - - /** - * Register a content:// URI for serving. Returns the token. - */ - fun registerUri(token: String, uri: Uri, mimeType: String, size: Long): String { - mediaEntries[token] = MediaEntry(uri = uri, file = null, mimeType = mimeType, size = size) - return token - } - - /** - * Register a file for serving (e.g., decrypted vault media). Returns the token. - */ - fun registerFile(token: String, file: File, mimeType: String): String { - mediaEntries[token] = MediaEntry(uri = null, file = file, mimeType = mimeType, size = file.length()) - return token - } - - fun unregister(token: String) { - mediaEntries.remove(token) - } - - fun unregisterAll() { - mediaEntries.clear() - } - - /** - * Build the full URL that the FCast receiver can use to fetch the media. - */ - fun getMediaUrl(token: String): String? { - if (!mediaEntries.containsKey(token)) return null - val ip = getLocalIpAddress() ?: return null - return "http://$ip:${listeningPort}/media/$token" - } - - override fun serve(session: IHTTPSession): Response { - val uri = session.uri ?: return newFixedLengthResponse( - Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found" - ) - - // Expect /media/{token} - if (!uri.startsWith("/media/")) { - return newFixedLengthResponse( - Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found" - ) - } - - val token = uri.removePrefix("/media/") - val entry = mediaEntries[token] ?: return newFixedLengthResponse( - Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found" - ) - - return try { - val inputStream: InputStream = when { - entry.file != null -> FileInputStream(entry.file) - entry.uri != null -> context.contentResolver.openInputStream(entry.uri) - ?: return newFixedLengthResponse( - Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Cannot open media" - ) - else -> return newFixedLengthResponse( - Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "No source" - ) - } - - val rangeHeader = session.headers["range"] - if (rangeHeader != null && entry.size > 0) { - serveRange(inputStream, entry, rangeHeader) - } else { - newFixedLengthResponse( - Response.Status.OK, - entry.mimeType, - inputStream, - entry.size - ) - } - } catch (e: Exception) { - newFixedLengthResponse( - Response.Status.INTERNAL_ERROR, - MIME_PLAINTEXT, - "Error: ${e.message}" - ) - } - } - - private fun serveRange( - inputStream: InputStream, - entry: MediaEntry, - rangeHeader: String - ): Response { - val totalSize = entry.size - // Parse "bytes=START-END" - val range = rangeHeader.replace("bytes=", "").trim() - val parts = range.split("-") - val start = parts[0].toLongOrNull() ?: 0L - val end = if (parts.size > 1 && parts[1].isNotBlank()) { - parts[1].toLong() - } else { - totalSize - 1 - } - - val contentLength = end - start + 1 - inputStream.skip(start) - - val response = newFixedLengthResponse( - Response.Status.PARTIAL_CONTENT, - entry.mimeType, - inputStream, - contentLength - ) - response.addHeader("Content-Range", "bytes $start-$end/$totalSize") - response.addHeader("Accept-Ranges", "bytes") - return response - } - - companion object { - fun getLocalIpAddress(): String? { - return try { - NetworkInterface.getNetworkInterfaces()?.toList() - ?.flatMap { it.inetAddresses.toList() } - ?.firstOrNull { !it.isLoopbackAddress && it is Inet4Address } - ?.hostAddress - } catch (_: Exception) { - null - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastButton.kt deleted file mode 100644 index d653bb9a9f..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastButton.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.RepeatMode -import androidx.compose.material3.LocalContentColor -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material.icons.outlined.CastConnected -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.dot.gallery.R - -@Composable -fun CastButton( - isConnected: Boolean, - isConnecting: Boolean, - followTheme: Boolean = false, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, - modifier: Modifier = Modifier -) { - val tint by animateColorAsState( - targetValue = if (followTheme) { - LocalContentColor.current - } else { - if (isConnected) Color.White else Color.White.copy(alpha = 0.8f) - }, - label = "CastButtonTint" - ) - - val alpha = if (isConnecting) { - val transition = rememberInfiniteTransition(label = "cast_connecting") - val a by transition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse - ), - label = "cast_alpha" - ) - a - } else 1f - - Box( - modifier = modifier - .size(48.dp) - .clip(CircleShape) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - .alpha(alpha), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isConnected) Icons.Outlined.CastConnected else Icons.Outlined.Cast, - contentDescription = stringResource( - if (isConnected) R.string.cast_disconnect else R.string.cast_connect - ), - tint = tint - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastPermissionsDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastPermissionsDialog.kt deleted file mode 100644 index fcc8080424..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/CastPermissionsDialog.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast.components - -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.material.icons.outlined.Cancel -import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.cast.CastPermission - -@Composable -fun CastPermissionsDialog( - permissions: List, - onDismiss: () -> Unit -) { - val context = LocalContext.current - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = Icons.Outlined.Cast, - contentDescription = null - ) - }, - title = { - Text(text = stringResource(R.string.cast_permissions_required)) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.cast_permissions_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) - permissions.forEach { perm -> - PermissionRow(perm) - } - } - }, - confirmButton = { - TextButton( - onClick = { - context.startActivity( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null) - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - ) - } - ) { - Text(stringResource(R.string.cast_open_settings)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.close)) - } - } - ) -} - -@Composable -private fun PermissionRow(permission: CastPermission) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = if (permission.granted) Icons.Outlined.CheckCircle else Icons.Outlined.Cancel, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (permission.granted) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - } - ) - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(permission.labelRes), - style = MaterialTheme.typography.bodyMedium, - color = if (permission.granted) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.error - } - ) - Text( - text = stringResource(permission.descriptionRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastDevicePickerDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastDevicePickerDialog.kt deleted file mode 100644 index 82ce86a086..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastDevicePickerDialog.kt +++ /dev/null @@ -1,289 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material.icons.outlined.CastConnected -import androidx.compose.material.icons.outlined.LinkOff -import androidx.compose.material.icons.outlined.PlayCircleOutline -import androidx.compose.material.icons.outlined.StopCircle -import androidx.compose.material.icons.outlined.Tv -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.cast.CastSessionState -import com.dot.gallery.feature_node.presentation.cast.FCastDevice -import com.dot.gallery.feature_node.presentation.cast.FCastProtocol - -@Composable -fun FCastDevicePickerDialog( - state: CastSessionState, - onDeviceSelected: (FCastDevice) -> Unit, - onCastMedia: () -> Unit = {}, - onStopCasting: () -> Unit = {}, - onDisconnect: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon( - imageVector = if (state.connectedDevice != null) { - Icons.Outlined.CastConnected - } else { - Icons.Outlined.Cast - }, - contentDescription = null - ) - }, - title = { - Text( - text = if (state.connectedDevice != null) { - stringResource(R.string.cast_connected_to, state.connectedDevice.name) - } else { - stringResource(R.string.cast_select_device) - } - ) - }, - text = { - Column { - if (state.connectedDevice != null) { - // "Cast this media" action - ActionItem( - icon = Icons.Outlined.PlayCircleOutline, - text = stringResource(R.string.cast_this_media), - onClick = { - onCastMedia() - onDismiss() - } - ) - - // "Stop current cast" action (only when actively casting) - if (state.castingMediaId != null) { - ActionItem( - icon = Icons.Outlined.StopCircle, - text = stringResource(R.string.cast_stop_current), - onClick = { - onStopCasting() - onDismiss() - } - ) - } - - // "Disconnect" action - ActionItem( - icon = Icons.Outlined.LinkOff, - text = stringResource(R.string.cast_disconnect), - onClick = onDisconnect, - tint = MaterialTheme.colorScheme.error - ) - } else { - if (state.isDiscovering && state.discoveredDevices.isEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp) - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(R.string.cast_searching), - style = MaterialTheme.typography.bodyMedium - ) - } - } - - if (state.discoveredDevices.isNotEmpty()) { - LazyColumn { - items(state.discoveredDevices) { device -> - DeviceItem( - device = device, - isConnecting = state.isConnecting, - onClick = { onDeviceSelected(device) } - ) - } - } - } - - if (state.connectionError != null) { - Spacer(Modifier.height(8.dp)) - Text( - text = state.connectionError, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - // Manual connection - Spacer(Modifier.height(12.dp)) - HorizontalDivider() - Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(R.string.cast_manual_connect), - style = MaterialTheme.typography.titleSmall - ) - Spacer(Modifier.height(8.dp)) - var manualIp by rememberSaveable { mutableStateOf("") } - var manualPort by rememberSaveable { mutableStateOf("") } - OutlinedTextField( - value = manualIp, - onValueChange = { manualIp = it.trim() }, - label = { Text(stringResource(R.string.cast_enter_ip)) }, - placeholder = { Text(stringResource(R.string.cast_ip_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = manualPort, - onValueChange = { manualPort = it.filter { c -> c.isDigit() } }, - label = { Text(stringResource(R.string.cast_port_hint)) }, - placeholder = { Text(FCastProtocol.DEFAULT_PORT.toString()) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f) - ) - TextButton( - onClick = { - if (manualIp.isNotBlank()) { - val port = manualPort.toIntOrNull() - ?: FCastProtocol.DEFAULT_PORT - onDeviceSelected( - FCastDevice( - name = manualIp, - host = manualIp, - port = port - ) - ) - } - }, - enabled = manualIp.isNotBlank() && !state.isConnecting - ) { - Text(stringResource(R.string.cast_connect_button)) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text( - stringResource( - if (state.connectedDevice != null) R.string.close - else R.string.cancel - ) - ) - } - } - ) -} - -@Composable -private fun DeviceItem( - device: FCastDevice, - isConnecting: Boolean, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, onClick = onClick) - .padding(vertical = 12.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Tv, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = device.name, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${device.host}:${device.port}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (isConnecting) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } - } -} - -@Composable -private fun ActionItem( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - onClick: () -> Unit, - tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 14.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = tint - ) - Spacer(Modifier.width(16.dp)) - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = tint - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastMiniController.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastMiniController.kt deleted file mode 100644 index a1909750d5..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/cast/components/FCastMiniController.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.cast.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CastConnected -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials - -/** - * Slim casting status banner showing the connected device name with a stop button. - * Supports blur matching the app bar style. Positioned in the top area below the date header. - */ -@OptIn(ExperimentalHazeMaterialsApi::class) -@Composable -fun CastStatusBanner( - deviceName: String, - onStop: () -> Unit, - onClick: () -> Unit = {}, - modifier: Modifier = Modifier -) { - val allowBlur by rememberAllowBlur() - val surfaceContainer = MaterialTheme.colorScheme.surfaceContainer.copy(0.5f) - val backgroundModifier = remember(allowBlur) { - if (!allowBlur) { - Modifier.background( - color = surfaceContainer, - shape = CircleShape - ) - } else Modifier - } - - Row( - modifier = modifier - .clip(CircleShape) - .then(backgroundModifier) - .hazeEffect( - state = LocalHazeState.current, - style = HazeMaterials.ultraThin( - containerColor = surfaceContainer - ) - ) - .clickable(onClick = onClick) - .padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.CastConnected, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.cast_casting_to, deviceName), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - IconButton( - onClick = onStop, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.cast_stop), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryScreen.kt index 557e67c9eb..b3543e7a23 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryScreen.kt @@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.rememberScrollState @@ -76,8 +76,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler @@ -86,14 +84,16 @@ import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationBackButton -import kotlinx.coroutines.launch -import com.dot.gallery.feature_node.domain.model.Category +import com.dot.gallery.feature_node.data.model.Category import com.dot.gallery.feature_node.domain.model.MediaMetadataState +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.hazeEffect +import kotlinx.coroutines.launch @OptIn( ExperimentalSharedTransitionApi::class, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryViewModel.kt index 18eb79c662..783f345352 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/AddCategoryViewModel.kt @@ -5,20 +5,22 @@ package com.dot.gallery.feature_node.presentation.classifier -import ai.onnxruntime.OrtSession +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.core.ml.ManagedOrtSession +import com.dot.gallery.core.ml.ModelInferenceException +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.search.SearchHelper import com.dot.gallery.feature_node.presentation.search.util.dot import com.dot.gallery.feature_node.presentation.util.mapMediaToItem import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -30,7 +32,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @HiltViewModel class AddCategoryViewModel @Inject constructor( @@ -78,13 +79,10 @@ class AddCategoryViewModel @Inject constructor( private val _saveSuccess = MutableStateFlow(false) val saveSuccess: StateFlow = _saveSuccess.asStateFlow() - private var textSession: OrtSession? = null + private var textSession: ManagedOrtSession? = null private var searchJob: Job? = null - // Image embeddings cache - private var imageEmbeddings: List = emptyList() - // Media cache for building MediaState - private var allMedia: List = emptyList() + private var mediaById: Map = emptyMap() init { loadData() @@ -92,9 +90,11 @@ class AddCategoryViewModel @Inject constructor( private fun loadData() { viewModelScope.launch(Dispatchers.IO) { - imageEmbeddings = repository.getImageEmbeddings().first() val mediaResource = repository.getMedia().first() - allMedia = mediaResource.data?.filterIsInstance() ?: emptyList() + mediaById = mediaResource.data + ?.filterIsInstance() + ?.associateBy { media -> media.id } + .orEmpty() } } @@ -148,26 +148,15 @@ class AddCategoryViewModel @Inject constructor( // Get text embedding for search terms val textEmbedding = searchHelper.getTextEmbedding(session, terms) - // Find matching images - val matches = mutableListOf>() val currentThreshold = _threshold.value - - imageEmbeddings.forEach { imageEmbedding -> + val sortedMatches = scoreImageEmbeddingPages(repository = repository) { imageEmbedding -> val similarity = textEmbedding.dot(imageEmbedding.embedding) - if (similarity >= currentThreshold) { - matches.add(imageEmbedding.id to similarity) + when { + similarity >= currentThreshold -> similarity + else -> null } } - - // Sort by similarity - val sortedMatches = matches.sortedByDescending { it.second } - val matchingIds = sortedMatches.map { it.first }.toSet() - - // Get the actual media objects - val matchingMedia = allMedia.filter { it.id in matchingIds } - .sortedByDescending { media -> - sortedMatches.find { it.first == media.id }?.second ?: 0f - } + val matchingMedia = sortedMatches.mapNotNull { (mediaId, _) -> mediaById[mediaId] } _previewCount.value = sortedMatches.size @@ -185,6 +174,10 @@ class AddCategoryViewModel @Inject constructor( _previewMediaState.value = mediaState } + } catch (e: ModelInferenceException) { + Log.w(TAG, "Model inference failed: ${e.message}", e) + _previewMediaState.value = MediaState() + _previewCount.value = 0 } finally { _isLoading.value = false } @@ -223,7 +216,13 @@ class AddCategoryViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) override fun onCleared() { - super.onCleared() + searchJob?.cancel() textSession?.close() + textSession = null + super.onCleared() + } + + private companion object { + private const val TAG = "AddCategoryViewModel" } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesScreen.kt index 9909da0fa0..afd06715d1 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -70,8 +71,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.LocalEventHandler @@ -79,17 +78,20 @@ import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.library.components.dashedBorder -import com.dot.gallery.feature_node.presentation.util.categorySharedElement import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen +import com.dot.gallery.feature_node.presentation.util.categorySharedElement import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.BlackScrim import com.dot.gallery.ui.theme.WhiterBlackScrim import com.dot.gallery.ui.theme.isDarkTheme @@ -364,7 +366,7 @@ private fun CategoryGridItem( GlideImage( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - model = media.getUri(), + model = media.toGlideModel(), contentDescription = categoryWithCount.name, requestBuilderTransform = { it.signature(GlideInvalidation.signature(media)) @@ -411,7 +413,11 @@ private fun CategoryGridItem( ) if (categoryWithCount.mediaCount > 0) { Text( - text = stringResource(R.string.category_media_count, categoryWithCount.mediaCount), + text = pluralStringResource( + id = R.plurals.item_count, + count = categoryWithCount.mediaCount, + categoryWithCount.mediaCount + ), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesSettingsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesSettingsScreen.kt index 991bb09e01..bbd1ca4e39 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesSettingsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesSettingsScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource @@ -29,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.Position import com.dot.gallery.core.SettingsEntity -import com.dot.gallery.core.Settings.Misc.rememberNoClassification import com.dot.gallery.core.ml.ModelStatus import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem import com.dot.gallery.feature_node.presentation.settings.components.SwitchPreferenceDetailScreen @@ -43,18 +41,27 @@ fun CategoriesSettingsScreen() { val categoryWorkerStatus by viewModel.categoryWorkerStatus.collectAsStateWithLifecycle() val categoriesWithCount by viewModel.categoriesWithCount.collectAsStateWithLifecycle() val modelStatus by viewModel.modelStatus.collectAsStateWithLifecycle() + val analysisSettings by viewModel.analysisSettings.collectAsStateWithLifecycle() val isModelReady = modelStatus == ModelStatus.READY - - var noClassification by rememberNoClassification() + val categoryControlsEnabled = isModelReady && + analysisSettings.analysisEnabled && + analysisSettings.categoryClassificationEnabled + val modelSummary = when (modelStatus) { + ModelStatus.CHECKING -> stringResource(R.string.ai_models_checking) + ModelStatus.READY -> null + ModelStatus.ERROR -> stringResource(R.string.ai_models_unavailable) + } val description = stringResource(R.string.disclaimer_classification) SwitchPreferenceDetailScreen( title = stringResource(R.string.categories_settings), - isChecked = !noClassification, - onCheckedChange = { noClassification = !it }, + isChecked = analysisSettings.categoryClassificationEnabled, + onCheckedChange = viewModel::setCategoryClassificationEnabled, switchLabel = stringResource(R.string.categorise_your_media), description = description, + enabled = analysisSettings.analysisEnabled && + (analysisSettings.categoryClassificationEnabled || isModelReady), customContent = { Column { // Scanner button @@ -64,17 +71,15 @@ fun CategoriesSettingsScreen() { stringResource(R.string.scanning_media) else stringResource(R.string.scan_for_new_categories), - summary = if (!isModelReady) - stringResource(R.string.ai_models_not_available) - else null, + summary = modelSummary, icon = Icons.Outlined.Scanner, screenPosition = if (categoriesWithCount.isNotEmpty() && !isCategoryWorkerRunning) Position.Top else Position.Alone ), modifier = Modifier - .alpha(if (isModelReady) 1f else 0.5f) + .alpha(if (categoryControlsEnabled) 1f else 0.5f) .combinedClickable( - enabled = isModelReady, + enabled = categoryControlsEnabled, onLongClick = { if (isCategoryWorkerRunning) viewModel.stopCategoryClassification() }, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesViewModel.kt index 453e372410..c1760c73d8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoriesViewModel.kt @@ -10,22 +10,19 @@ import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.core.ml.ModelStatus import com.dot.gallery.core.workers.CategoryWorker -import com.dot.gallery.core.workers.VaultOperationWorker -import com.dot.gallery.core.workers.enqueueVaultOperation -import com.dot.gallery.core.workers.startCategoryClassification -import com.dot.gallery.feature_node.domain.util.getUri import com.dot.gallery.core.workers.startClassification -import com.dot.gallery.core.workers.stopCategoryClassification import com.dot.gallery.core.workers.stopClassification -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSettings import com.dot.gallery.feature_node.presentation.util.update import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -34,35 +31,30 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject /** * ViewModel for the Categories feature. * Manages both the legacy classification system and the new embedding-based category system. */ @HiltViewModel -class CategoriesViewModel @Inject constructor( +class CategoriesViewModel @Inject internal constructor( private val repository: MediaRepository, private val distributor: MediaDistributor, private val workManager: WorkManager, + private val aiMediaAnalysis: AiMediaAnalysis, private val modelManager: ModelManager ) : ViewModel() { val modelStatus: StateFlow = modelManager.status - // ============ Locations ============ - - /** - * Flow of all locations with their media - */ - val locations = distributor.locationsMediaFlow - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - /** - * Flow of all geo-tagged media with GPS coordinates for map display - */ - val geoMedia = distributor.geoMediaFlow - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + internal val analysisSettings: StateFlow = aiMediaAnalysis.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = AiMediaAnalysisSettings( + analysisEnabled = false, + categoryClassificationEnabled = true, + ), + ) // ============ New Category System ============ @@ -87,21 +79,21 @@ class CategoriesViewModel @Inject constructor( /** * Worker state for the new category classification */ - val isCategoryWorkerRunning: StateFlow = workManager.getWorkInfosByTagFlow(CategoryWorker.TAG) - .map { workInfos -> workInfos.any { it.state == State.RUNNING || it.state == State.ENQUEUED } } + val isCategoryWorkerRunning: StateFlow = aiMediaAnalysis.workState + .map { workState -> + workState.isCategoryActive + } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val categoryWorkerProgress: StateFlow = workManager.getWorkInfosByTagFlow(CategoryWorker.TAG) - .map { workInfos -> - workInfos.firstOrNull { it.state == State.RUNNING } - ?.progress?.getFloat(CategoryWorker.KEY_PROGRESS, 0f) ?: 0f + val categoryWorkerProgress: StateFlow = aiMediaAnalysis.workState + .map { workState -> + workState.categoryProgress } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0f) - val categoryWorkerStatus: StateFlow = workManager.getWorkInfosByTagFlow(CategoryWorker.TAG) - .map { workInfos -> - workInfos.firstOrNull { it.state == State.RUNNING } - ?.progress?.getString(CategoryWorker.KEY_STATUS) ?: "" + val categoryWorkerStatus: StateFlow = aiMediaAnalysis.workState + .map { workState -> + workState.categoryStatus } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") @@ -152,11 +144,17 @@ class CategoriesViewModel @Inject constructor( * Start the new embedding-based category classification */ fun startCategoryClassification() { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { // Initialize default categories if needed repository.initializeDefaultCategories() // Start the worker - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() + } + } + + fun setCategoryClassificationEnabled(enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + aiMediaAnalysis.setCategoryClassificationEnabled(enabled = enabled) } } @@ -164,7 +162,9 @@ class CategoriesViewModel @Inject constructor( * Stop the category classification worker */ fun stopCategoryClassification() { - workManager.stopCategoryClassification() + viewModelScope.launch(Dispatchers.IO) { + aiMediaAnalysis.cancelCategoryClassification() + } } /** @@ -179,7 +179,7 @@ class CategoriesViewModel @Inject constructor( ) repository.createCategory(category) // Trigger reclassification to include the new category - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } } @@ -193,7 +193,7 @@ class CategoriesViewModel @Inject constructor( embedding = null // Clear embedding so it gets regenerated )) // Trigger reclassification - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } } @@ -204,7 +204,7 @@ class CategoriesViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { repository.updateCategoryThreshold(categoryId, threshold.coerceIn(Category.MIN_THRESHOLD, Category.MAX_THRESHOLD)) // Trigger reclassification - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } } @@ -260,7 +260,7 @@ class CategoriesViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { repository.resetCategoryData() repository.initializeDefaultCategories() - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } } @@ -289,21 +289,15 @@ class CategoriesViewModel @Inject constructor( } } - fun addMedia(vault: Vault, media: T) { - workManager.enqueueVaultOperation( - operation = VaultOperationWorker.OP_ENCRYPT, - media = listOf(media.getUri()), - vault = vault - ) - } - /** * Start the category classification using the new CLIP-based system. * This replaces the old ONNX-based classification. */ fun startClassification() { // Use the new CLIP-based category classification - workManager.startCategoryClassification() + viewModelScope.launch(Dispatchers.IO) { + aiMediaAnalysis.requestCategoryClassification() + } } fun deleteClassifications() { @@ -317,8 +311,10 @@ class CategoriesViewModel @Inject constructor( */ fun stopClassification() { // Stop both the old and new systems for safety - workManager.stopCategoryClassification() + viewModelScope.launch(Dispatchers.IO) { + aiMediaAnalysis.cancelCategoryClassification() + } workManager.stopClassification() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorScreen.kt index a11e801d32..8d72666349 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorScreen.kt @@ -56,7 +56,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FloatingActionButton @@ -64,17 +63,18 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -105,8 +105,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler @@ -114,15 +112,18 @@ import com.dot.gallery.core.Settings.Misc.rememberGridSize import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.search.ImageSearchPickerSheet import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen +import com.dot.gallery.feature_node.presentation.util.toGlideModel import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.hazeEffect import kotlinx.coroutines.launch @@ -735,7 +736,7 @@ private fun SensitivitySection( @OptIn(ExperimentalGlideComposeApi::class) @Composable private fun InlinePreviewSection( - previewMedia: List, + previewMedia: List, previewCount: Int, isLoading: Boolean, searchTerms: String, @@ -812,7 +813,7 @@ private fun InlinePreviewSection( key = { it.id } ) { media -> GlideImage( - model = media.getUri(), + model = media.toGlideModel(), contentDescription = null, modifier = Modifier .aspectRatio(1f) @@ -925,7 +926,7 @@ private fun ReferenceImagesSection( .clip(RoundedCornerShape(12.dp)) ) { GlideImage( - model = media.getUri(), + model = media.toGlideModel(), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, @@ -958,4 +959,3 @@ private fun ReferenceImagesSection( } } } - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorViewModel.kt index b74b08d7a8..95c682123e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryEditorViewModel.kt @@ -5,22 +5,23 @@ package com.dot.gallery.feature_node.presentation.classifier -import ai.onnxruntime.OrtSession +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.core.ml.ManagedOrtSession +import com.dot.gallery.core.ml.ModelInferenceException +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis import com.dot.gallery.feature_node.presentation.search.SearchHelper import com.dot.gallery.feature_node.presentation.search.util.dot import com.dot.gallery.feature_node.presentation.util.mapMediaToItem -import androidx.work.WorkManager -import com.dot.gallery.core.workers.startCategoryClassification import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -31,17 +32,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject /** * Unified ViewModel for both creating and editing categories. * When [categoryId] is null, operates in create mode; otherwise in edit mode. */ @HiltViewModel -class CategoryEditorViewModel @Inject constructor( +class CategoryEditorViewModel @Inject internal constructor( private val repository: MediaRepository, private val searchHelper: SearchHelper, - private val workManager: WorkManager + private val aiMediaAnalysis: AiMediaAnalysis, ) : ViewModel() { // Date format settings @@ -113,14 +113,13 @@ class CategoryEditorViewModel @Inject constructor( // ============ Internal ============ - private var textSession: OrtSession? = null + private var textSession: ManagedOrtSession? = null private var searchJob: Job? = null - private var imageEmbeddings: List = emptyList() - private val _allMedia = MutableStateFlow>(emptyList()) val allMedia: StateFlow> = _allMedia.asStateFlow() private var allMediaList: List = emptyList() + private var mediaById: Map = emptyMap() init { loadData() @@ -128,9 +127,9 @@ class CategoryEditorViewModel @Inject constructor( private fun loadData() { viewModelScope.launch(Dispatchers.IO) { - imageEmbeddings = repository.getImageEmbeddings().first() val mediaResource = repository.getMedia().first() allMediaList = mediaResource.data?.filterIsInstance() ?: emptyList() + mediaById = allMediaList.associateBy { media -> media.id } _allMedia.value = allMediaList _isDataReady.value = true } @@ -250,43 +249,30 @@ class CategoryEditorViewModel @Inject constructor( // Collect reference image embeddings val refEmbeddings = if (refIds.isNotEmpty()) { - imageEmbeddings.filter { it.id in refIds } + repository.getImageEmbeddingsByIds(ids = refIds.toSet()) } else emptyList() if (textEmbedding == null && refEmbeddings.isEmpty()) return@withContext - val matches = mutableListOf>() val currentThreshold = _threshold.value val refIdSet = refIds.toSet() - - imageEmbeddings.forEach { imageEmbedding -> - // Skip reference images themselves - if (imageEmbedding.id in refIdSet) return@forEach - + val sortedMatches = scoreImageEmbeddingPages(repository = repository) { imageEmbedding -> + if (imageEmbedding.id in refIdSet) { + return@scoreImageEmbeddingPages null + } var bestScore = 0f - - // Text-to-image similarity if (textEmbedding != null) { bestScore = maxOf(bestScore, textEmbedding.dot(imageEmbedding.embedding)) } - - // Image-to-image similarity (against each reference) refEmbeddings.forEach { ref -> bestScore = maxOf(bestScore, ref.embedding.dot(imageEmbedding.embedding)) } - - if (bestScore >= currentThreshold) { - matches.add(imageEmbedding.id to bestScore) + when { + bestScore >= currentThreshold -> bestScore + else -> null } } - - val sortedMatches = matches.sortedByDescending { it.second } - val matchingIds = sortedMatches.map { it.first }.toSet() - - val matchingMedia = allMediaList.filter { it.id in matchingIds } - .sortedByDescending { media -> - sortedMatches.find { it.first == media.id }?.second ?: 0f - } + val matchingMedia = sortedMatches.mapNotNull { (mediaId, _) -> mediaById[mediaId] } _previewCount.value = sortedMatches.size _previewMedia.value = matchingMedia @@ -304,6 +290,11 @@ class CategoryEditorViewModel @Inject constructor( _previewMediaState.value = mediaState } + } catch (e: ModelInferenceException) { + Log.w(TAG, "Model inference failed: ${e.message}", e) + _previewMedia.value = emptyList() + _previewMediaState.value = MediaState() + _previewCount.value = 0 } finally { _isLoading.value = false } @@ -334,7 +325,7 @@ class CategoryEditorViewModel @Inject constructor( ) repository.updateCategory(updatedCategory) if (searchTermsChanged || thresholdChanged || refImagesChanged) { - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } } else { val category = Category( @@ -345,7 +336,7 @@ class CategoryEditorViewModel @Inject constructor( isUserCreated = true ) repository.createCategory(category) - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } _saveSuccess.value = true withContext(Dispatchers.Main) { @@ -374,7 +365,13 @@ class CategoryEditorViewModel @Inject constructor( } override fun onCleared() { - super.onCleared() + searchJob?.cancel() textSession?.close() + textSession = null + super.onCleared() + } + + private companion object { + private const val TAG = "CategoryEditorViewModel" } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewModel.kt index d58839518d..0875bb8128 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewModel.kt @@ -3,23 +3,20 @@ package com.dot.gallery.feature_node.presentation.classifier import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.Constants import androidx.work.WorkManager +import com.dot.gallery.core.Constants import com.dot.gallery.core.MediaDistributor -import com.dot.gallery.core.workers.VaultOperationWorker -import com.dot.gallery.core.workers.enqueueVaultOperation -import com.dot.gallery.feature_node.domain.util.getUri import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.add import com.dot.gallery.feature_node.presentation.util.mapMediaToItem import com.dot.gallery.feature_node.presentation.util.remove import com.dot.gallery.feature_node.presentation.util.update import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -32,7 +29,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject /** * ViewModel for viewing media in a single category. @@ -167,14 +163,6 @@ class CategoryViewModel @Inject constructor( } } - fun addMedia(vault: Vault, media: T) { - workManager.enqueueVaultOperation( - operation = VaultOperationWorker.OP_ENCRYPT, - media = listOf(media.getUri()), - vault = vault - ) - } - /** * Remove selected media from this category */ @@ -199,4 +187,4 @@ class CategoryViewModel @Inject constructor( } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewScreen.kt index 21797168d6..f5e3290575 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/CategoryViewScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,7 +51,11 @@ fun CategoryViewScreen( MediaScreen( albumName = category, - customDateHeader = stringResource(R.string.s_items, mediaState.value.media.size), + customDateHeader = pluralStringResource( + id = R.plurals.item_count, + count = mediaState.value.media.size, + mediaState.value.media.size + ), mediaState = mediaState, metadataState = metadataState, target = "category_$category", @@ -98,7 +103,11 @@ fun CategoryViewScreen( MediaScreen( albumName = categoryName, - customDateHeader = stringResource(R.string.s_items, mediaState.value.media.size), + customDateHeader = pluralStringResource( + id = R.plurals.item_count, + count = mediaState.value.media.size, + mediaState.value.media.size + ), mediaState = mediaState, metadataState = metadataState, target = "category_id_$categoryId", diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryScreen.kt index f630d4553c..556d0eaa9d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryScreen.kt @@ -69,8 +69,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler @@ -80,7 +78,9 @@ import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationBackButton import com.dot.gallery.feature_node.domain.model.MediaMetadataState +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen import dev.chrisbanes.haze.LocalHazeStyle diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryViewModel.kt index fd6759b1fe..554e90f507 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/EditCategoryViewModel.kt @@ -5,22 +5,23 @@ package com.dot.gallery.feature_node.presentation.classifier -import ai.onnxruntime.OrtSession +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Category -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.core.ml.ManagedOrtSession +import com.dot.gallery.core.ml.ModelInferenceException +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis import com.dot.gallery.feature_node.presentation.search.SearchHelper import com.dot.gallery.feature_node.presentation.search.util.dot import com.dot.gallery.feature_node.presentation.util.mapMediaToItem -import androidx.work.WorkManager -import com.dot.gallery.core.workers.startCategoryClassification import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -31,13 +32,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @HiltViewModel -class EditCategoryViewModel @Inject constructor( +class EditCategoryViewModel @Inject internal constructor( private val repository: MediaRepository, private val searchHelper: SearchHelper, - private val workManager: WorkManager + private val aiMediaAnalysis: AiMediaAnalysis, ) : ViewModel() { // Date format settings @@ -57,10 +57,10 @@ class EditCategoryViewModel @Inject constructor( ).stateIn(viewModelScope, SharingStarted.Eagerly, Constants.WEEKLY_DATE_FORMAT) private val _categoryId = MutableStateFlow(null) - + private val _category = MutableStateFlow(null) val category: StateFlow = _category.asStateFlow() - + private val _categoryName = MutableStateFlow("") val categoryName: StateFlow = _categoryName.asStateFlow() @@ -75,31 +75,29 @@ class EditCategoryViewModel @Inject constructor( private val _previewCount = MutableStateFlow(0) val previewCount: StateFlow = _previewCount.asStateFlow() - + private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow = _isLoading.asStateFlow() - + private val _isSaving = MutableStateFlow(false) val isSaving: StateFlow = _isSaving.asStateFlow() - private var textSession: OrtSession? = null + private var textSession: ManagedOrtSession? = null private var searchJob: Job? = null - // Image embeddings cache - private var imageEmbeddings: List = emptyList() - // Media cache for building MediaState - private var allMedia: List = emptyList() - + private var mediaById: Map = emptyMap() + fun loadCategory(categoryId: Long) { _categoryId.value = categoryId viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true try { - // Load embeddings and media first - imageEmbeddings = repository.getImageEmbeddings().first() val mediaResource = repository.getMedia().first() - allMedia = mediaResource.data?.filterIsInstance() ?: emptyList() - + mediaById = mediaResource.data + ?.filterIsInstance() + ?.associateBy { media -> media.id } + .orEmpty() + // Load category details val cat = repository.getCategoryAsync(categoryId) if (cat != null) { @@ -107,7 +105,7 @@ class EditCategoryViewModel @Inject constructor( _categoryName.value = cat.name _searchTerms.value = cat.searchTerms _threshold.value = cat.threshold - + // Trigger initial preview search searchPreview(cat.searchTerms) } @@ -116,11 +114,11 @@ class EditCategoryViewModel @Inject constructor( } } } - + fun updateCategoryName(name: String) { _categoryName.value = name } - + fun updateSearchTerms(terms: String) { _searchTerms.value = terms // Trigger preview search with debounce @@ -130,7 +128,7 @@ class EditCategoryViewModel @Inject constructor( searchPreview(terms) } } - + fun updateThreshold(value: Float) { _threshold.value = value // Re-run preview with new threshold @@ -161,28 +159,17 @@ class EditCategoryViewModel @Inject constructor( // Get text embedding for search terms val textEmbedding = searchHelper.getTextEmbedding(session, terms) - - // Find matching images - val matches = mutableListOf>() - val currentThreshold = _threshold.value - imageEmbeddings.forEach { imageEmbedding -> + val currentThreshold = _threshold.value + val sortedMatches = scoreImageEmbeddingPages(repository = repository) { imageEmbedding -> val similarity = textEmbedding.dot(imageEmbedding.embedding) - if (similarity >= currentThreshold) { - matches.add(imageEmbedding.id to similarity) + when { + similarity >= currentThreshold -> similarity + else -> null } } + val matchingMedia = sortedMatches.mapNotNull { (mediaId, _) -> mediaById[mediaId] } - // Sort by similarity - val sortedMatches = matches.sortedByDescending { it.second } - val matchingIds = sortedMatches.map { it.first }.toSet() - - // Get the actual media objects - val matchingMedia = allMedia.filter { it.id in matchingIds } - .sortedByDescending { media -> - sortedMatches.find { it.first == media.id }?.second ?: 0f - } - _previewCount.value = sortedMatches.size // Build MediaState @@ -196,21 +183,25 @@ class EditCategoryViewModel @Inject constructor( extendedDateFormat = extendedDateFormat.value, weeklyDateFormat = weeklyDateFormat.value ) - + _previewMediaState.value = mediaState } + } catch (e: ModelInferenceException) { + Log.w(TAG, "Model inference failed: ${e.message}", e) + _previewMediaState.value = MediaState() + _previewCount.value = 0 } catch (e: Exception) { // Handle error silently } } - + fun saveCategory(onComplete: () -> Unit) { val categoryId = _categoryId.value ?: return val name = _categoryName.value.trim() val terms = _searchTerms.value.trim() - + if (name.isBlank()) return - + viewModelScope.launch(Dispatchers.IO) { _isSaving.value = true try { @@ -226,7 +217,7 @@ class EditCategoryViewModel @Inject constructor( ) repository.updateCategory(updatedCategory) if (searchTermsChanged || thresholdChanged) { - workManager.startCategoryClassification() + aiMediaAnalysis.requestCategoryClassification() } withContext(Dispatchers.Main) { onComplete() @@ -236,10 +227,10 @@ class EditCategoryViewModel @Inject constructor( } } } - + fun deleteCategory(onComplete: () -> Unit) { val categoryId = _categoryId.value ?: return - + viewModelScope.launch(Dispatchers.IO) { _isSaving.value = true try { @@ -254,7 +245,13 @@ class EditCategoryViewModel @Inject constructor( } override fun onCleared() { - super.onCleared() + searchJob?.cancel() textSession?.close() + textSession = null + super.onCleared() + } + + private companion object { + private const val TAG = "EditCategoryViewModel" } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorer.kt new file mode 100644 index 0000000000..e0943e7983 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorer.kt @@ -0,0 +1,37 @@ +package com.dot.gallery.feature_node.presentation.classifier + +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.repository.MediaRepository +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +internal suspend fun scoreImageEmbeddingPages( + repository: MediaRepository, + scoreEmbedding: (ImageEmbedding) -> Float?, +): List> { + val matches = mutableListOf>() + var afterId = Long.MIN_VALUE + + while (true) { + currentCoroutineContext().ensureActive() + val page = repository.getImageEmbeddingPage( + afterId = afterId, + limit = EMBEDDING_PAGE_SIZE, + ) + + if (page.isEmpty()) { + break + } + + page.forEach { imageEmbedding -> + scoreEmbedding(imageEmbedding)?.let { score -> + matches += imageEmbedding.id to score + } + } + afterId = page.last().id + } + + return matches.sortedByDescending { (_, score) -> score } +} + +private const val EMBEDDING_PAGE_SIZE = 256 diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/LocationsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/LocationsScreen.kt deleted file mode 100644 index a3b305c05c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/LocationsScreen.kt +++ /dev/null @@ -1,300 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.classifier - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.R -import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle -import com.dot.gallery.feature_node.presentation.util.GlideInvalidation -import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.ui.theme.BlackScrim -import com.dot.gallery.ui.theme.WhiterBlackScrim -import com.dot.gallery.ui.theme.isDarkTheme - -/** - * Data class to group locations by country - */ -data class CountryLocations( - val country: String, - val locations: List -) - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class) -@Composable -fun LocationsScreen( - metadataState: State, -) { - val eventHandler = LocalEventHandler.current - val viewModel = hiltViewModel() - - // Get locations from the ViewModel - val locations by viewModel.locations.collectAsStateWithLifecycle() - - // Group locations by country - val locationsByCountry by remember(locations) { - derivedStateOf { - locations - .groupBy { locationMedia -> - // Extract country from "City, Country" format - locationMedia.location.substringAfterLast(", ").trim() - } - .map { (country, locationMediaList) -> - CountryLocations( - country = country, - locations = locationMediaList.sortedBy { it.location } - ) - } - .sortedBy { it.country } - } - } - - val totalLocationsCount by remember(locations) { - derivedStateOf { locations.size } - } - - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = { - TwoLinedDateToolbarTitle( - albumName = stringResource(R.string.locations), - dateHeader = stringResource(R.string.locations_count, totalLocationsCount) - ) - }, - navigationIcon = { - NavigationBackButton() - }, - scrollBehavior = scrollBehavior - ) - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - - // Location rows grouped by country - items( - items = locationsByCountry, - key = { it.country } - ) { countryLocations -> - CountryLocationRow( - countryLocations = countryLocations, - onLocationClick = { location -> - val gpsLocationNameCity = location.substringBefore(",").trim() - val gpsLocationNameCountry = location.substringAfterLast(", ").trim() - eventHandler.navigate( - Screen.LocationTimelineScreen.location( - gpsLocationNameCity = gpsLocationNameCity, - gpsLocationNameCountry = gpsLocationNameCountry - ) - ) - } - ) - } - - // Empty state - if (locationsByCountry.isEmpty()) { - item(key = "empty_state") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.no_locations_found), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } - } - } - } -} - -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -private fun CountryLocationRow( - countryLocations: CountryLocations, - onLocationClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxWidth() - .animateContentSize(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Country header - Text( - text = countryLocations.country, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp) - ) - - // Horizontal scroll of locations for this country - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - items( - items = countryLocations.locations, - key = { it.location } - ) { locationMedia -> - LocationCard( - locationMedia = locationMedia, - onClick = { onLocationClick(locationMedia.location) } - ) - } - } - } -} - -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -private fun LocationCard( - locationMedia: LocationMedia, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val isDarkTheme = isDarkTheme() - val allowBlur by rememberAllowBlur() - val followTheme = remember(allowBlur) { !allowBlur } - val gradientColor by animateColorAsState( - if (followTheme) { - if (isDarkTheme) BlackScrim else WhiterBlackScrim - } else BlackScrim, - label = "gradientColor" - ) - - // Extract just the city name for display - val cityName = locationMedia.location.substringBefore(",").trim() - - Box( - modifier = modifier - .width(164.dp) - .height(220.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - ) { - // Get the URI properly based on media type - val media = locationMedia.media - val uri = when (media) { - is Media.UriMedia -> media.getUri() - else -> null - } - - if (uri != null) { - GlideImage( - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - model = uri, - contentDescription = locationMedia.location, - requestBuilderTransform = { - if (media is Media.UriMedia) { - it.signature(GlideInvalidation.signature(media)) - } else { - it - } - } - ) - } else { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant) - ) - } - - // Gradient overlay with city name - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor) - ) - ) - .padding(16.dp) - ) { - Text( - text = cityName, - style = MaterialTheme.typography.titleSmall, - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - modifier = Modifier.fillMaxWidth() - ) - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/components/CategoryCarousel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/components/CategoryCarousel.kt index c803c0af4d..470bd95ed9 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/components/CategoryCarousel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/classifier/components/CategoryCarousel.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -44,11 +45,11 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.library.CategoryMedia import com.dot.gallery.feature_node.presentation.search.SearchMediaItem import com.dot.gallery.feature_node.presentation.util.GlideInvalidation +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.BlackScrim import com.dot.gallery.ui.theme.WhiterBlackScrim import com.dot.gallery.ui.theme.isDarkTheme @@ -111,7 +112,7 @@ fun CategoryCarousel( GlideImage( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - model = categoryMedia.thumbnailMedia.getUri(), + model = categoryMedia.thumbnailMedia.toGlideModel(), contentDescription = categoryMedia.category.name, requestBuilderTransform = { it.signature(GlideInvalidation.signature(categoryMedia.thumbnailMedia)) @@ -157,8 +158,9 @@ fun CategoryCarousel( maxLines = 1 ) Text( - text = stringResource( - R.string.category_media_count, + text = pluralStringResource( + id = R.plurals.item_count, + count = categoryMedia.category.mediaCount, categoryMedia.category.mediaCount ), style = MaterialTheme.typography.bodySmall, @@ -172,98 +174,9 @@ fun CategoryCarousel( } } -/** - * A horizontal carousel of location recommendations for the search screen. - * Matches the LibraryScreen location carousel style. - */ -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -fun LocationCarousel( - locations: ImmutableList, - onLocationClick: (LocationMedia) -> Unit, - modifier: Modifier = Modifier, - title: String? = stringResource(R.string.locations), - contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp) -) { - if (locations.isEmpty()) return - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (title != null) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(contentPadding) - ) - } - - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = contentPadding, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - items( - items = locations, - key = { it.location } - ) { locationMedia -> - val isDarkTheme = isDarkTheme() - val allowBlur by rememberAllowBlur() - val followTheme = remember(allowBlur) { !allowBlur } - val gradientColor by animateColorAsState( - if (followTheme) { - if (isDarkTheme) BlackScrim else WhiterBlackScrim - } else BlackScrim, - ) - Box( - modifier = Modifier - .width(164.dp) - .height(256.dp) - .clip(RoundedCornerShape(24.dp)) - .clickable { onLocationClick(locationMedia) }, - ) { - GlideImage( - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - model = locationMedia.media.getUri(), - contentDescription = locationMedia.location, - requestBuilderTransform = { - it.signature(GlideInvalidation.signature(locationMedia.media)) - } - ) - Text( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - gradientColor - ) - ) - ) - .padding(24.dp), - text = locationMedia.location, - style = MaterialTheme.typography.titleMedium, - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 2 - ) - } - } - } - } -} - /** * A generic horizontal carousel for dynamic search items (MIME types, lens models, media modes, etc.). - * Uses the same visual style as CategoryCarousel / LocationCarousel. + * Uses the same visual style as CategoryCarousel. */ @OptIn(ExperimentalGlideComposeApi::class) @Composable @@ -318,7 +231,7 @@ fun SearchCarousel( GlideImage( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - model = item.media.getUri(), + model = item.media.toGlideModel(), contentDescription = item.label, requestBuilderTransform = { it.signature(GlideInvalidation.signature(item.media)) @@ -365,8 +278,9 @@ fun SearchCarousel( ) if (item.count > 0) { Text( - text = stringResource( - R.string.category_media_count, + text = pluralStringResource( + id = R.plurals.item_count, + count = item.count, item.count ), style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionAlbumSelectorScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionAlbumSelectorScreen.kt index 157225f5d8..bea01cbadb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionAlbumSelectorScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionAlbumSelectorScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar @@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.presentation.components.NavigationBackButton +import com.dot.gallery.core.presentation.components.SetupButton import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem import com.dot.gallery.feature_node.presentation.settings.components.settings diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewModel.kt index b28cd4eb6f..9409bcd3b4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewModel.kt @@ -6,16 +6,16 @@ package com.dot.gallery.feature_node.presentation.collection import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.feature_node.domain.model.Collection -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.Collection +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.repository.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class CollectionViewModel @Inject constructor( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewScreen.kt index ca72c8599c..873ae41a59 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/CollectionViewScreen.kt @@ -43,8 +43,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler @@ -60,16 +58,18 @@ import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationBackButton import com.dot.gallery.core.presentation.components.SelectionSheet -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.albumtimeline.components.AlbumSortDropdown +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.MosaicMediaGrid import com.dot.gallery.feature_node.presentation.common.components.MosaicPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.TimelineScroller -import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState +import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/AddToCollectionSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/AddToCollectionSheet.kt index 5f5f73622b..5479bc4615 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/AddToCollectionSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/AddToCollectionSheet.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +54,8 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.load.engine.DiskCacheStrategy import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.presentation.util.toGlideModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class) @Composable @@ -195,7 +197,7 @@ fun AddToCollectionSheet( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - model = thumbnailUri, + model = thumbnailUri.toGlideModel(), contentDescription = cwc.collection.label, contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -223,7 +225,11 @@ fun AddToCollectionSheet( overflow = TextOverflow.Ellipsis ) Text( - text = stringResource(R.string.n_items_in_collection, cwc.mediaCount), + text = pluralStringResource( + id = R.plurals.item_count, + count = cwc.mediaCount, + cwc.mediaCount + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/CollectionComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/CollectionComponent.kt index 54ef333d01..b58a9bc902 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/CollectionComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/collection/components/CollectionComponent.kt @@ -6,7 +6,6 @@ package com.dot.gallery.feature_node.presentation.collection.components import android.content.ContentUris import android.provider.MediaStore -import com.dot.gallery.feature_node.presentation.util.formatSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication @@ -45,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -52,11 +52,13 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.load.engine.DiskCacheStrategy import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.CollectionWithCount import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet +import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel import kotlinx.coroutines.launch @OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class) @@ -198,7 +200,7 @@ fun CollectionComponent( scope.launch { appBottomSheetState.show() } } ), - model = thumbnailUri, + model = thumbnailUri.toGlideModel(), contentDescription = collection.label, contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -267,8 +269,9 @@ fun CollectionComponent( modifier = Modifier .padding(top = 2.dp, bottom = 16.dp) .padding(horizontal = 16.dp), - text = stringResource( - R.string.n_items_in_collection, + text = pluralStringResource( + id = R.plurals.item_count, + count = collectionWithCount.mediaCount, collectionWithCount.mediaCount ) + sizeText, overflow = TextOverflow.Ellipsis, @@ -411,7 +414,7 @@ fun CollectionRowComponent( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(12.dp)), - model = thumbnailUri, + model = thumbnailUri.toGlideModel(), contentDescription = collection.label, contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -455,8 +458,9 @@ fun CollectionRowComponent( " (${formatSize(collectionWithCount.totalSize)})" } else "" Text( - text = stringResource( - R.string.n_items_in_collection, + text = pluralStringResource( + id = R.plurals.item_count, + count = collectionWithCount.mediaCount, collectionWithCount.mediaCount ) + sizeText, style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt index 2ca8e49d2a..a60d8dafa8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt @@ -45,8 +45,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.Target.TARGET_TRASH @@ -64,15 +62,17 @@ import com.dot.gallery.core.presentation.components.NavigationActions import com.dot.gallery.core.presentation.components.NavigationButton import com.dot.gallery.core.presentation.components.SelectionSheet import com.dot.gallery.core.toggleNavigationBar -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.MosaicMediaGrid import com.dot.gallery.feature_node.presentation.common.components.MosaicPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.TimelineScroller -import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState +import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/GridPinchZoomState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/GridPinchZoomState.kt index 02fe174c22..73c529193e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/GridPinchZoomState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/GridPinchZoomState.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight @@ -56,8 +57,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch import kotlin.math.abs +import kotlinx.coroutines.launch /** * Scope provided by [GridPinchZoomLayout] to its content. @@ -143,7 +144,7 @@ fun GridPinchZoomLayout( var lastSnapZone = 0 do { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val canceled = event.changes.any { it.isConsumed } if (!canceled && event.changes.size >= 2) { val zoomChange = event.calculateZoom() diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt index 51685bf799..f9b8272a95 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -34,6 +35,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection @@ -50,12 +52,12 @@ import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.presentation.components.Error import com.dot.gallery.core.presentation.components.LoadingMedia import com.dot.gallery.core.presentation.components.MediaItemHeader -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.model.isBigHeaderKey +import com.dot.gallery.feature_node.data.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isBigHeaderKey -import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.mediaSharedElement import com.dot.gallery.feature_node.presentation.util.photoGridDragHandler @@ -110,24 +112,38 @@ fun GridPinchZoomScope.MediaGrid( Column( modifier = Modifier.padding(paddingValues).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center ) { - AnimatedVisibility( - visible = mediaState.value.isLoading, - enter = enterAnimation, - exit = exitAnimation - ) { - LoadingMedia() + if (aboveGridContent != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { } + ) { + aboveGridContent() + } } - AnimatedVisibility( - visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, - enter = enterAnimation, - exit = exitAnimation + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - emptyContent() - } - AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { - Error(errorMessage = mediaState.value.error) + AnimatedVisibility( + visible = mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingMedia() + } + AnimatedVisibility( + visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + emptyContent() + } + AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { + Error(errorMessage = mediaState.value.error) + } } } } @@ -223,6 +239,14 @@ private fun GridPinchZoomScope.MediaGridContentWithHeaders( val selector = LocalMediaSelector.current val isSelectionActive by selector.isSelectionActive.collectAsStateWithLifecycle() val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() + val selectedIds = selectedMedia.value + val selectionOrderById = remember(selectedIds) { + selectedIds.withIndex().associate { (index, id) -> id to index + 1 } + } + val mediaIndexById = remember(mediaState.value.media) { + mediaState.value.media.withIndex().associate { (index, media) -> media.id to index } + } + val metadataById = metadataState.value.metadataById // Prune stale selection IDs when media list changes (e.g. external file deletion) val mediaIds = remember(mediaState.value.media) { @@ -347,17 +371,22 @@ private fun GridPinchZoomScope.MediaGridContentWithHeaders( ) .pinchItem(key = it.key), media = it.media, + metadata = metadataById[it.media.id], + selectionActive = isSelectionActive, + isSelected = it.media.id in selectedIds, + selectionNumber = selectionOrderById[it.media.id], stackCount = it.stackCount, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, onItemSelect = { if (allowSelection) { feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) + mediaIndexById[it.id]?.let { index -> + selector.toggleSelection( + mediaState = mediaState.value, + index = index, + ) + } } } ) @@ -413,6 +442,15 @@ private fun GridPinchZoomScope.MediaGridContent( } val selector = LocalMediaSelector.current val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() + val isSelectionActive by selector.isSelectionActive.collectAsStateWithLifecycle() + val selectedIds = selectedMedia.value + val selectionOrderById = remember(selectedIds) { + selectedIds.withIndex().associate { (index, id) -> id to index + 1 } + } + val mediaIndexById = remember(items) { + items.withIndex().associate { (index, media) -> media.id to index } + } + val metadataById = metadataState.value.metadataById LazyVerticalGrid( state = gridState, @@ -457,21 +495,25 @@ private fun GridPinchZoomScope.MediaGridContent( ) .pinchItem(key = media.key), media = media, - metadataState = metadataState, + metadata = metadataById[media.id], + selectionActive = isSelectionActive, + isSelected = media.id in selectedIds, + selectionNumber = selectionOrderById[media.id], canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, onItemSelect = { if (allowSelection) { - val index = items.indexOf(it) feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = index - ) + mediaIndexById[it.id]?.let { index -> + selector.toggleSelection( + mediaState = mediaState.value, + index = index, + ) + } } } ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt index bb6aacb6f5..0361e83cc4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt @@ -47,11 +47,11 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.isHeaderKey +import com.dot.gallery.feature_node.data.util.isIgnoredKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isHeaderKey -import com.dot.gallery.feature_node.domain.util.isIgnoredKey import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.roundDpToPx import com.dot.gallery.feature_node.presentation.util.roundSpToPx diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt index 8a1725e721..aed713ab77 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt @@ -12,9 +12,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -28,7 +28,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,20 +40,17 @@ import androidx.compose.ui.unit.dp import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.Settings.Misc.rememberFavoriteIconPosition -import androidx.compose.ui.util.fastFirstOrNull -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.core.presentation.components.LocalMediaImageRenderer import com.dot.gallery.core.presentation.components.CheckBox +import com.dot.gallery.core.presentation.components.LocalMediaImageRenderer import com.dot.gallery.core.presentation.components.util.advancedShadow -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.model.getIcon -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isFavorite -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.model.getIcon +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isFavorite +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoDurationHeader -import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState +import com.dot.gallery.feature_node.presentation.util.toGlideModel import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -66,23 +62,16 @@ import dev.chrisbanes.haze.rememberHazeState fun MediaImage( modifier: Modifier = Modifier, media: T, - metadataState: State, + metadata: MediaMetadata?, + selectionActive: Boolean, + isSelected: Boolean, + selectionNumber: Int?, stackCount: Int = 1, aspectRatio: Float = 1f, canClick: () -> Boolean, onMediaClick: (T) -> Unit, onItemSelect: (T) -> Unit, ) { - val selector = LocalMediaSelector.current - val selectionState by selector.isSelectionActive.collectAsStateWithLifecycle() - val selectedMedia by selector.selectedMedia.collectAsStateWithLifecycle() - val isSelected by rememberedDerivedState(selectionState, selectedMedia, media) { - selectionState && selectedMedia.any { it == media.id } - } - val metadata by rememberedDerivedState(metadataState.value) { - metadataState.value.metadata.fastFirstOrNull { it.mediaId == media.id } - } - val selectedSize by animateDpAsState( targetValue = if (isSelected) 12.dp else 0.dp, label = "selectedSize" @@ -116,13 +105,13 @@ fun MediaImage( .combinedClickable( enabled = canClick(), onClick = { - if (selectionState) { + if (selectionActive) { onItemSelect(media) } else { onMediaClick(media) } }, - onLongClick = if (selectionState) { + onLongClick = if (selectionActive) { null // No long click action when selection is active } else { { onItemSelect(media) } @@ -150,7 +139,7 @@ fun MediaImage( shape = roundedShape, color = strokeColor ), - model = media.getUri(), + model = media.toGlideModel(), contentDescription = media.label, contentScale = ContentScale.Crop, signature = media @@ -220,7 +209,8 @@ fun MediaImage( ) } - if (metadata != null && metadata!!.isRelevant) { + val metadataIcon = metadata?.takeIf { value -> value.isRelevant }?.getIcon() + if (metadataIcon != null) { Icon( modifier = Modifier .align(Alignment.BottomStart) @@ -233,26 +223,21 @@ fun MediaImage( shadowBlurRadius = 6.dp, alpha = 0.3f ), - imageVector = metadata!!.getIcon()!!, + imageVector = metadataIcon, tint = Color.White, contentDescription = null ) } - if (selectionState) { + if (selectionActive) { Box( modifier = Modifier .fillMaxWidth() .padding(4.dp) ) { - val number by rememberedDerivedState { - if (isSelected) { - selectedMedia.indexOf(media.id) + 1 - } else null - } CheckBox( isChecked = isSelected, - number = number + number = selectionNumber, ) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicMediaGrid.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicMediaGrid.kt index 003786a159..731fbe8ede 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicMediaGrid.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicMediaGrid.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -33,6 +34,7 @@ import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -44,6 +46,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection @@ -58,26 +61,26 @@ import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.presentation.components.Error import com.dot.gallery.core.presentation.components.LoadingMedia import com.dot.gallery.core.presentation.components.MediaItemHeader -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.model.isBigHeaderKey +import com.dot.gallery.feature_node.data.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.domain.model.MosaicDisplayItem import com.dot.gallery.feature_node.domain.model.MosaicTilePattern -import com.dot.gallery.feature_node.domain.model.isBigHeaderKey -import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.mosaicPatternsForColumns import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.mediaSharedElement import com.dot.gallery.feature_node.presentation.util.mosaicGridDragHandler import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.random.Random fun buildMosaicDisplayItems( mappedData: List>, @@ -229,25 +232,48 @@ fun MosaicMediaGrid( } } - val displayItems = remember(mappedData, allowHeaders, columns) { - if (allowHeaders) buildMosaicDisplayItems(mappedData, columns) - else buildMosaicDisplayItems(mappedData.filterIsInstance>(), columns) + val mappedDataSnapshot by remember(mappedData) { + derivedStateOf { mappedData.toList() } + } + var displayItems by remember { mutableStateOf>>(emptyList()) } + LaunchedEffect(mappedDataSnapshot, allowHeaders, columns) { + displayItems = withContext(Dispatchers.Default) { + val source = when { + allowHeaders -> mappedDataSnapshot + else -> mappedDataSnapshot.filterIsInstance>() + } + buildMosaicDisplayItems(mappedData = source, columns = columns) + } } val bottomContent: @Composable () -> Unit = { Column( modifier = Modifier.padding(paddingValues).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center ) { - AnimatedVisibility(visible = mediaState.value.isLoading, enter = enterAnimation, exit = exitAnimation) { - LoadingMedia() - } - AnimatedVisibility(visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, enter = enterAnimation, exit = exitAnimation) { - emptyContent() + if (aboveGridContent != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { } + ) { + aboveGridContent() + } } - AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { - Error(errorMessage = mediaState.value.error) + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AnimatedVisibility(visible = mediaState.value.isLoading, enter = enterAnimation, exit = exitAnimation) { + LoadingMedia() + } + AnimatedVisibility(visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, enter = enterAnimation, exit = exitAnimation) { + emptyContent() + } + AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { + Error(errorMessage = mediaState.value.error) + } } } } @@ -265,6 +291,25 @@ fun MosaicMediaGrid( val selector = LocalMediaSelector.current val isSelectionActive by selector.isSelectionActive.collectAsStateWithLifecycle() val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() + val selectedIds = selectedMedia.value + val selectionOrderById = remember(selectedIds) { + selectedIds.withIndex().associate { (index, id) -> id to index + 1 } + } + val mediaIndexById = remember(mediaState.value.media) { + mediaState.value.media.withIndex().associate { (index, media) -> media.id to index } + } + val metadataById = metadataState.value.metadataById + val selectMedia: (T) -> Unit = { media -> + if (allowSelection) { + mediaIndexById[media.id]?.let { index -> + feedbackManager.vibrate() + selector.toggleSelection( + mediaState = mediaState.value, + index = index, + ) + } + } + } // Prune stale selection IDs when media list changes (e.g. external file deletion) val mediaIds = remember(mediaState.value.media) { @@ -442,20 +487,15 @@ fun MosaicMediaGrid( ) .animateItem(fadeInSpec = null, fadeOutSpec = spring()), media = mi.media, + metadata = metadataById[mi.media.id], + selectionActive = isSelectionActive, + isSelected = mi.media.id in selectedIds, + selectionNumber = selectionOrderById[mi.media.id], stackCount = mi.stackCount, aspectRatio = item.dynamicAspectRatio, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, - onItemSelect = { - if (allowSelection) { - feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) - } - } + onItemSelect = selectMedia, ) } } @@ -479,19 +519,14 @@ fun MosaicMediaGrid( animatedVisibilityScope = animatedContentScope ), media = mi.media, + metadata = metadataById[mi.media.id], + selectionActive = isSelectionActive, + isSelected = mi.media.id in selectedIds, + selectionNumber = selectionOrderById[mi.media.id], stackCount = mi.stackCount, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, - onItemSelect = { - if (allowSelection) { - feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) - } - } + onItemSelect = selectMedia, ) } } else Spacer(Modifier.weight(1f)) @@ -514,19 +549,14 @@ fun MosaicMediaGrid( animatedVisibilityScope = animatedContentScope ), media = mi.media, + metadata = metadataById[mi.media.id], + selectionActive = isSelectionActive, + isSelected = mi.media.id in selectedIds, + selectionNumber = selectionOrderById[mi.media.id], stackCount = mi.stackCount, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, - onItemSelect = { - if (allowSelection) { - feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) - } - } + onItemSelect = selectMedia, ) } } else Spacer(Modifier.weight(1f)) @@ -554,19 +584,14 @@ fun MosaicMediaGrid( animatedVisibilityScope = animatedContentScope ), media = mi.media, + metadata = metadataById[mi.media.id], + selectionActive = isSelectionActive, + isSelected = mi.media.id in selectedIds, + selectionNumber = selectionOrderById[mi.media.id], stackCount = mi.stackCount, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, - onItemSelect = { - if (allowSelection) { - feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) - } - } + onItemSelect = selectMedia, ) } } else Spacer(Modifier.weight(1f)) @@ -586,19 +611,14 @@ fun MosaicMediaGrid( ) .animateItem(fadeInSpec = null, fadeOutSpec = spring()), media = mi.media, + metadata = metadataById[mi.media.id], + selectionActive = isSelectionActive, + isSelected = mi.media.id in selectedIds, + selectionNumber = selectionOrderById[mi.media.id], stackCount = mi.stackCount, canClick = { canScroll }, onMediaClick = { onMediaClick(it) }, - metadataState = metadataState, - onItemSelect = { - if (allowSelection) { - feedbackManager.vibrate() - selector.toggleSelection( - mediaState = mediaState.value, - index = mediaState.value.media.indexOf(it) - ) - } - } + onItemSelect = selectMedia, ) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicPinchZoomState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicPinchZoomState.kt index e361651972..1d3a438ced 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicPinchZoomState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MosaicPinchZoomState.kt @@ -5,6 +5,7 @@ package com.dot.gallery.feature_node.presentation.common.components +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -26,9 +27,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -47,7 +48,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import android.view.HapticFeedbackConstants +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight @@ -56,8 +57,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dot.gallery.core.Constants.mosaicColumnsList -import kotlinx.coroutines.launch import kotlin.math.abs +import kotlinx.coroutines.launch @Stable class MosaicPinchZoomState( @@ -124,7 +125,7 @@ fun MosaicPinchZoomLayout( var lastSnapZone = 0 do { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val canceled = event.changes.any { it.isConsumed } if (!canceled && event.changes.size >= 2) { val zoomChange = event.calculateZoom() diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt index d16452cf30..73c4e40070 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt @@ -32,8 +32,8 @@ import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Settings.Misc.rememberDefaultDateFormat import com.dot.gallery.core.Settings.Misc.rememberExtendedDateFormat import com.dot.gallery.core.Settings.Misc.rememberWeeklyDateFormat -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.getDate import my.nanihadesuka.compose.InternalLazyVerticalGridScrollbar diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt index 95dffa0996..c226b65b18 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt @@ -1,7 +1,6 @@ package com.dot.gallery.feature_node.presentation.common.components import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.animateIntOffsetAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Box @@ -100,23 +99,14 @@ inline fun StickyHeaderLayout( } } } - val toolbarOffsetValue by remember(headerOffset, toolbarOffset()) { - derivedStateOf { - toolbarOffset() - } - } + val toolbarOffsetValue = toolbarOffset() - val offsetAnimation by animateIntOffsetAsState( - remember(headerOffset, toolbarOffsetValue) { - IntOffset( - x = 0, - y = headerOffset + toolbarOffsetValue - ) - }, label = "offsetAnimation" - ) + val totalOffsetY by remember { + derivedStateOf { headerOffset + toolbarOffsetValue } + } val alphaAnimation by animateFloatAsState( - targetValue = remember(offsetAnimation) { if (offsetAnimation.y < -100) 0f else 1f }, + targetValue = if (totalOffsetY < -100) 0f else 1f, label = "alphaAnimation", animationSpec = tween(100, 10), ) @@ -128,7 +118,7 @@ inline fun StickyHeaderLayout( .graphicsLayer { alpha = alphaAnimation } - .offset { offsetAnimation } + .offset { IntOffset(x = 0, y = totalOffsetY) } ) { stickyHeader() } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt index dfe135bdb8..2935f3cb29 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt @@ -9,11 +9,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.model.isHeaderKey +import com.dot.gallery.feature_node.data.model.isSmallHeaderKey import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isHeaderKey -import com.dot.gallery.feature_node.domain.model.isSmallHeaderKey import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState @Composable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt index d092d0adf8..1fc55f0fde 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt @@ -13,24 +13,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.core.view.WindowCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.feature_node.presentation.edit.adjustments.Crop import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.printError -import com.dot.gallery.feature_node.presentation.util.launchWriteRequest -import com.dot.gallery.feature_node.presentation.util.rememberActivityResult -import com.dot.gallery.feature_node.presentation.util.writeRequest import com.dot.gallery.ui.theme.GalleryTheme import dagger.hilt.android.AndroidEntryPoint import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @AndroidEntryPoint class EditActivity : ComponentActivity() { @@ -72,9 +65,6 @@ class EditActivity : ComponentActivity() { val currentImage by viewModel.currentBitmap.collectAsStateWithLifecycle() val targetImage by viewModel.targetBitmap.collectAsStateWithLifecycle() val uri by viewModel.uri.collectAsStateWithLifecycle() - val canOverride by viewModel.canOverride.collectAsStateWithLifecycle() - val hasOriginalBackup by viewModel.hasOriginalBackup.collectAsStateWithLifecycle() - val isReverting by viewModel.isReverting.collectAsStateWithLifecycle() val appliedAdjustments by viewModel.appliedAdjustments.collectAsStateWithLifecycle() val isSaving by viewModel.isSaving.collectAsStateWithLifecycle() val previewMatrix by viewModel.previewMatrix.collectAsStateWithLifecycle() @@ -100,41 +90,7 @@ class EditActivity : ComponentActivity() { val previewRotation90 by viewModel.previewRotation90.collectAsStateWithLifecycle() val previewFlipH by viewModel.previewFlipH.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope { Dispatchers.IO } - - val doOverride: () -> Unit = { - viewModel.saveOverride( - onSuccess = { - finish() - }, - onFail = { - printError("Failed to save override") - } - ) - } - val overrideRequest = rememberActivityResult( - onResultOk = doOverride - ) - - val doRevert: () -> Unit = { - viewModel.revertToOriginal( - onSuccess = { - finish() - }, - onFail = { - printError("Failed to revert to original") - } - ) - } - val revertRequest = rememberActivityResult( - onResultOk = doRevert - ) - - EditScreen2( - hasOriginalBackup = hasOriginalBackup, - isReverting = isReverting, - canOverride = canOverride, isChanged = appliedAdjustments.isNotEmpty(), isSaving = isSaving, isProcessing = isProcessing, @@ -155,16 +111,6 @@ class EditActivity : ComponentActivity() { onClose = { finish() }, - onOverride = { - scope.launch { - uri?.let { uri -> - overrideRequest.launchWriteRequest( - uri.writeRequest(contentResolver), - doOverride - ) - } - } - }, onSaveCopy = { viewModel.saveCopy( onSuccess = { @@ -196,16 +142,6 @@ class EditActivity : ComponentActivity() { undoLastPath = viewModel::undoLastPath, redoLastPath = viewModel::redoLastPath, clearDrawing = viewModel::clearDrawingBoard, - onRevertToOriginal = { - scope.launch { - uri?.let { uri -> - revertRequest.launchWriteRequest( - uri.writeRequest(contentResolver), - doRevert - ) - } - } - }, canUndo = canUndo, canRedo = canRedo, onRedo = viewModel::redoLast, @@ -232,4 +168,4 @@ class EditActivity : ComponentActivity() { } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt index 5c4168387e..d81858991e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt @@ -2,6 +2,7 @@ package com.dot.gallery.feature_node.presentation.edit import android.graphics.Bitmap import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring @@ -34,8 +35,6 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Crop import androidx.compose.material.icons.outlined.Flip import androidx.compose.material.icons.outlined.GridOn -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -76,7 +75,6 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.activity.compose.BackHandler import androidx.navigation.compose.rememberNavController import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation @@ -104,9 +102,6 @@ import dev.chrisbanes.haze.hazeSource @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun EditScreen2( - hasOriginalBackup: Boolean = false, - isReverting: Boolean = false, - canOverride: Boolean = false, canSave: Boolean = true, isChanged: Boolean = false, isSaving: Boolean = false, @@ -126,7 +121,6 @@ fun EditScreen2( currentPathProperty: PathProperties, currentPath: Path, onClose: () -> Unit, - onOverride: () -> Unit, onSaveCopy: () -> Unit, onAdjustItemLongClick: (VariableFilterTypes) -> Unit, onAdjustmentChange: (Adjustment) -> Unit, @@ -147,7 +141,6 @@ fun EditScreen2( undoLastPath: () -> Unit, redoLastPath: () -> Unit, clearDrawing: () -> Unit = {}, - onRevertToOriginal: () -> Unit = {}, canUndo: Boolean = false, canRedo: Boolean = false, onRedo: () -> Unit = {}, @@ -193,8 +186,6 @@ fun EditScreen2( wasDrawing = isMarkupDrawing } - var showRevertDialog by remember { mutableStateOf(false) } - // Track which tab is currently selected for the tab bar highlight var selectedTab by remember { mutableStateOf(EditorItems.Lighting) } val showingEditorScreen by rememberedDerivedState { @@ -225,13 +216,10 @@ fun EditScreen2( } val animatedBlurRadius by animateDpAsState( - if (isSaving || isReverting || cropState.isCropping || requestMarkupApply) 50.dp else 0.dp, + if (isSaving || cropState.isCropping || requestMarkupApply) 50.dp else 0.dp, label = "animatedBlurRadius" ) - // 3-dot menu state - var showMenu by remember { mutableStateOf(false) } - // Aspect ratio state for crop var selectedAspectRatio by remember { mutableStateOf(AspectRatio.Original) } var showAspectMenu by remember { mutableStateOf(false) } @@ -248,7 +236,7 @@ fun EditScreen2( modifier = Modifier .hazeSource(LocalHazeState.current) .fillMaxSize() - .then(if (isSaving || isReverting || cropState.isCropping || requestMarkupApply) Modifier.blur(animatedBlurRadius) else Modifier) + .then(if (isSaving || cropState.isCropping || requestMarkupApply) Modifier.blur(animatedBlurRadius) else Modifier) .background(Color.Black) .systemBarsPadding() ) { @@ -355,78 +343,6 @@ fun EditScreen2( ) } } - // Only show 3-dot menu if there are actions available - val hasMenuActions = (isChanged && canOverride) || isChanged || hasOriginalBackup - AnimatedVisibility( - visible = hasMenuActions, - enter = enterAnimation, - exit = exitAnimation - ) { - Box { - IconButton( - onClick = { showMenu = true }, - enabled = !isProcessing, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.editor_more_options), - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - if (isChanged && canOverride) { - DropdownMenuItem( - text = { - Column { - Text(stringResource(R.string.override)) - Text( - text = stringResource(R.string.editor_save_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - onClick = { - showMenu = false - onOverride() - } - ) - } - if (isChanged) { - DropdownMenuItem( - text = { - Column { - Text(stringResource(R.string.save_copy)) - Text( - text = stringResource(R.string.editor_save_copy_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - onClick = { - showMenu = false - onSaveCopy() - } - ) - } - if (hasOriginalBackup) { - DropdownMenuItem( - text = { Text(stringResource(R.string.revert_to_original)) }, - onClick = { - showMenu = false - showRevertDialog = true - } - ) - } - } - } - } // end AnimatedVisibility for 3-dot menu } } } // end AnimatedVisibility for top bar @@ -900,7 +816,7 @@ fun EditScreen2( // Loading overlay AnimatedVisibility( - visible = isSaving || isReverting || requestMarkupApply, + visible = isSaving || requestMarkupApply, enter = enterAnimation, exit = exitAnimation ) { @@ -934,29 +850,5 @@ fun EditScreen2( } ) } - - // Revert dialog - if (showRevertDialog) { - AlertDialog( - onDismissRequest = { showRevertDialog = false }, - title = { Text(stringResource(R.string.revert_to_original)) }, - text = { Text(stringResource(R.string.revert_to_original_confirmation)) }, - confirmButton = { - Button( - onClick = { - showRevertDialog = false - onRevertToOriginal() - } - ) { - Text(stringResource(R.string.action_revert)) - } - }, - dismissButton = { - TextButton(onClick = { showRevertDialog = false }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt index d7292297ac..87490a2144 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt @@ -15,36 +15,33 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.dot.gallery.core.EditBackupManager import com.dot.gallery.core.MediaHandler -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.editor.SaveFormat +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.editor.Adjustment import com.dot.gallery.feature_node.domain.model.editor.DrawMode import com.dot.gallery.feature_node.domain.model.editor.DrawType import com.dot.gallery.feature_node.domain.model.editor.ImageFilter import com.dot.gallery.feature_node.domain.model.editor.PathProperties -import com.dot.gallery.feature_node.domain.model.editor.SaveFormat import com.dot.gallery.feature_node.domain.model.editor.SuggestionPreset import com.dot.gallery.feature_node.domain.model.editor.VariableFilter -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.edit.adjustments.Flip import com.dot.gallery.feature_node.presentation.edit.adjustments.Markup import com.dot.gallery.feature_node.presentation.edit.adjustments.Rotate90CW -import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Rotate import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Denoise +import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Rotate import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Sharpness -import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Vignette import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes -import com.dot.gallery.feature_node.presentation.util.overlayBitmaps +import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.Vignette import com.dot.gallery.feature_node.presentation.util.applyColorMatrix -import com.dot.gallery.core.workers.EditBackupWorker -import com.dot.gallery.core.workers.revertEditBackup +import com.dot.gallery.feature_node.presentation.util.overlayBitmaps import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.printError +import com.dot.gallery.feature_node.presentation.util.toGlideModel import dagger.hilt.android.lifecycle.HiltViewModel -import androidx.work.WorkInfo -import androidx.work.WorkManager +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -54,14 +51,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import javax.inject.Inject @HiltViewModel class EditViewModel @Inject constructor( private val repository: MediaRepository, private val mediaHandler: MediaHandler, - private val editBackupManager: EditBackupManager, - private val workManager: WorkManager, ) : ViewModel() { private val _originalBitmap = MutableStateFlow(null) @@ -95,15 +89,6 @@ class EditViewModel @Inject constructor( private val _isSaving = MutableStateFlow(true) val isSaving = _isSaving.asStateFlow() - private val _canOverride = MutableStateFlow(false) - val canOverride = _canOverride.asStateFlow() - - private val _hasOriginalBackup = MutableStateFlow(false) - val hasOriginalBackup = _hasOriginalBackup.asStateFlow() - - private val _isReverting = MutableStateFlow(false) - val isReverting = _isReverting.asStateFlow() - private val _isProcessing = MutableStateFlow(false) val isProcessing = _isProcessing.asStateFlow() @@ -233,9 +218,9 @@ class EditViewModel @Inject constructor( private fun bestSaveFormat(): SaveFormat { val mime = activeMedia.value?.mimeType?.lowercase() return when { - mime?.contains("png") == true -> SaveFormat.PNG - mime?.contains("webp") == true -> SaveFormat.WEBP_LOSSY - else -> SaveFormat.JPEG // JPEG is the fast default for photos + mime?.contains("png") == true -> SaveFormat.Png + mime?.contains("webp") == true -> SaveFormat.WebpLossy + else -> SaveFormat.Jpeg // JPEG is the fast default for photos } } @@ -363,10 +348,8 @@ class EditViewModel @Inject constructor( val mediaList = repository.getMediaListByUris(listOf(uri), reviewMode = false, onlyMatching = true).firstOrNull()?.data ?: emptyList() - _canOverride.value = mediaList.isNotEmpty() if (mediaList.isNotEmpty()) { activeMedia.value = mediaList.first() - _hasOriginalBackup.value = editBackupManager.hasOriginalBackup(mediaList.first().id) } else { activeMedia.value = Media.createFromUri(context, uri) } @@ -378,7 +361,7 @@ class EditViewModel @Inject constructor( private fun setOriginalBitmap(context: Context) { viewModelScope.launch(Dispatchers.IO) { val result = Glide.with(context) - .load(activeMedia.value?.uri) + .load(activeMedia.value?.toGlideModel()) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.NONE) .submit() @@ -746,7 +729,7 @@ class EditViewModel @Inject constructor( try { if (mediaHandler.saveImage( bitmap = bitmap, - format = format.format, + format = format.compressFormat, relativePath = Environment.DIRECTORY_PICTURES + "/Edited", displayName = media.label, mimeType = format.mimeType @@ -764,91 +747,4 @@ class EditViewModel @Inject constructor( } } - fun saveOverride( - saveFormat: SaveFormat? = null, - onSuccess: () -> Unit = {}, - onFail: () -> Unit = {} - ) { - viewModelScope.launch(Dispatchers.IO) { - _isSaving.value = true - val format = saveFormat ?: bestSaveFormat() - // Flatten any pending matrix adjustments into the bitmap before saving - flattenComposedMatrix() - val media = activeMedia.value!! - lastRealBitmap()?.let { bitmap -> - try { - // Backup original before overriding (preserves first original) - editBackupManager.backupOriginal( - mediaId = media.id, - uri = media.uri, - mimeType = media.mimeType - ) - - if (mediaHandler.overrideImage( - uri = media.uri, - bitmap = bitmap, - format = format.format, - relativePath = Environment.DIRECTORY_PICTURES + "/Edited", - displayName = media.label, - mimeType = format.mimeType - ) - ) { - _hasOriginalBackup.value = true - onSuccess().also { _isSaving.value = false } - } else { - onFail().also { _isSaving.value = false } - } - } catch (e: Exception) { - onFail().also { _isSaving.value = false } - } - } ?: onFail().also { _isSaving.value = false } - } - } - - fun revertToOriginal( - onSuccess: () -> Unit = {}, - onFail: () -> Unit = {} - ) { - viewModelScope.launch { - _isReverting.value = true - val media = activeMedia.value - if (media == null) { - _isReverting.value = false - onFail() - return@launch - } - try { - val workId = workManager.revertEditBackup(media.id) - workManager.getWorkInfoByIdFlow(workId).collect { info -> - if (info == null) return@collect - when (info.state) { - WorkInfo.State.SUCCEEDED -> { - val success = info.outputData.getBoolean( - EditBackupWorker.KEY_SUCCESS, false - ) - if (success) { - _hasOriginalBackup.value = false - _isReverting.value = false - onSuccess() - } else { - _isReverting.value = false - onFail() - } - return@collect - } - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - _isReverting.value = false - onFail() - return@collect - } - else -> { /* ENQUEUED, RUNNING, BLOCKED – keep waiting */ } - } - } - } catch (e: Exception) { - printError("Failed to revert: ${e.message}") - _isReverting.value = false - onFail() - } - } - } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/colour/ColourSection.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/colour/ColourSection.kt index 60b29ec16d..cb4d00a663 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/colour/ColourSection.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/colour/ColourSection.kt @@ -13,10 +13,10 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.unit.dp import com.dot.gallery.feature_node.domain.model.editor.Adjustment import com.dot.gallery.feature_node.domain.model.editor.ColourTool +import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes import com.dot.gallery.feature_node.presentation.edit.components.adjustment.SelectableItem import com.dot.gallery.feature_node.presentation.edit.components.core.SupportiveLazyLayout import com.dot.gallery.feature_node.presentation.edit.utils.isApplied -import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes @Composable fun ColourSection( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/core/HorizontalScrubber.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/core/HorizontalScrubber.kt index fef08438b3..5901c49edc 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/core/HorizontalScrubber.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/core/HorizontalScrubber.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.dot.gallery.feature_node.presentation.util.horizontalFadingEdge diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/EditorNavigator.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/EditorNavigator.kt index d3c2f5e3c4..19a8733b30 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/EditorNavigator.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/EditorNavigator.kt @@ -20,6 +20,7 @@ import com.dot.gallery.feature_node.domain.model.editor.EditorItems import com.dot.gallery.feature_node.domain.model.editor.ImageFilter import com.dot.gallery.feature_node.domain.model.editor.MarkupItems import com.dot.gallery.feature_node.domain.model.editor.PathProperties +import com.dot.gallery.feature_node.domain.model.editor.TextAnnotation import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes import com.dot.gallery.feature_node.presentation.edit.components.adjustment.AdjustScrubber import com.dot.gallery.feature_node.presentation.edit.components.colour.ColourSection @@ -29,7 +30,6 @@ import com.dot.gallery.feature_node.presentation.edit.components.lighting.Lighti import com.dot.gallery.feature_node.presentation.edit.components.lighting.toVariableFilterType import com.dot.gallery.feature_node.presentation.edit.components.markup.MarkupSelector import com.dot.gallery.feature_node.presentation.edit.components.markup.MarkupToolSelector -import com.dot.gallery.feature_node.domain.model.editor.TextAnnotation import kotlin.math.roundToInt @Composable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/ImageViewer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/ImageViewer.kt index e5d89b3c08..2a3d9ea175 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/ImageViewer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/editor/ImageViewer.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -25,16 +26,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import com.dot.gallery.core.Constants.Animation.enterAnimation diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/lighting/LightingSection.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/lighting/LightingSection.kt index 7644c71813..5cb4726446 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/lighting/LightingSection.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/lighting/LightingSection.kt @@ -13,10 +13,10 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.unit.dp import com.dot.gallery.feature_node.domain.model.editor.Adjustment import com.dot.gallery.feature_node.domain.model.editor.LightingTool +import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes import com.dot.gallery.feature_node.presentation.edit.components.adjustment.SelectableItem import com.dot.gallery.feature_node.presentation.edit.components.core.SupportiveLazyLayout import com.dot.gallery.feature_node.presentation.edit.utils.isApplied -import com.dot.gallery.feature_node.presentation.edit.adjustments.varfilter.VariableFilterTypes @Composable fun LightingSection( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/ColorBars.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/ColorBars.kt index adf3a229d8..ad2d39848c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/ColorBars.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/ColorBars.kt @@ -1,6 +1,7 @@ package com.dot.gallery.feature_node.presentation.edit.components.markup import android.graphics.Bitmap +import android.graphics.Color as AndroidColor import android.graphics.Paint import android.graphics.RectF import androidx.compose.foundation.Canvas @@ -39,7 +40,6 @@ import androidx.core.graphics.toRect import com.dot.gallery.feature_node.presentation.edit.components.core.SupportiveLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import android.graphics.Color as AndroidColor @Composable fun AlphaBar( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupPainter.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupPainter.kt index 7275154afb..f67e1e0496 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupPainter.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupPainter.kt @@ -28,13 +28,13 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onSizeChanged diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupSelector.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupSelector.kt index 36f11a8011..5a8fb8bccb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupSelector.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/MarkupSelector.kt @@ -10,9 +10,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -36,17 +36,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.dot.gallery.R import com.dot.gallery.feature_node.domain.model.editor.DrawMode import com.dot.gallery.feature_node.domain.model.editor.DrawType import com.dot.gallery.feature_node.domain.model.editor.MarkupItems import com.dot.gallery.feature_node.domain.model.editor.PathProperties import com.dot.gallery.feature_node.domain.model.editor.TextAnnotation -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R import com.dot.gallery.feature_node.presentation.edit.components.adjustment.SelectableItem import com.dot.gallery.feature_node.presentation.edit.components.core.SupportiveLayout import com.dot.gallery.feature_node.presentation.edit.components.core.SupportiveLazyLayout diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/TextMarkupOverlay.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/TextMarkupOverlay.kt index 926e3582be..62863c53c3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/TextMarkupOverlay.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/markup/TextMarkupOverlay.kt @@ -33,10 +33,10 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.feature_node.presentation.util.horizontalFadingEdge diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropActivity.kt new file mode 100644 index 0000000000..c7a639b1cc --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropActivity.kt @@ -0,0 +1,66 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.dot.gallery.R +import com.dot.gallery.feature_node.data.externalcrop.ExternalCropIntentParser +import com.dot.gallery.ui.theme.GalleryTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class CropActivity : ComponentActivity() { + + @Inject + internal lateinit var externalCropIntentParser: ExternalCropIntentParser + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val request = externalCropIntentParser.parse( + intent = intent, + caller = initialCaller, + ) + + if (request == null) { + finishCanceled() + return + } + + setResult(RESULT_CANCELED) + enableEdgeToEdge() + + setContent { + GalleryTheme { + CropScreen( + request = request, + onFinishCanceled = ::finishCanceled, + onFinishWithResult = ::finishWithResult, + onShowSaveErrorAndCancel = ::showSaveErrorAndCancel, + ) + } + } + } + + private fun finishWithResult(resultIntent: Intent) { + setResult(RESULT_OK, resultIntent) + finish() + } + + private fun showSaveErrorAndCancel() { + Toast + .makeText(this, getString(R.string.error_toast), Toast.LENGTH_SHORT) + .show() + + finishCanceled() + } + + private fun finishCanceled() { + setResult(RESULT_CANCELED) + finish() + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropEffect.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropEffect.kt new file mode 100644 index 0000000000..2b6100e3ad --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropEffect.kt @@ -0,0 +1,23 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import android.content.Intent +import android.content.IntentSender + +internal sealed interface CropEffect { + + data object FinishCanceled : CropEffect + + data object ShowSaveErrorAndCancel : CropEffect + + /** + * Ask the user to grant write access to the caller-supplied output uri. Not terminal — the save + * resumes once the system dialog returns. + */ + data class RequestOutputWritePermission( + val intentSender: IntentSender, + ) : CropEffect + + data class FinishWithResult( + val resultIntent: Intent, + ) : CropEffect +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropScreen.kt new file mode 100644 index 0000000000..fc6788ab9a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropScreen.kt @@ -0,0 +1,299 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect +import com.smarttoolfactory.cropper.ImageCropper +import com.smarttoolfactory.cropper.model.AspectRatio +import com.smarttoolfactory.cropper.model.OutlineType +import com.smarttoolfactory.cropper.model.RectCropShape +import com.smarttoolfactory.cropper.settings.CropDefaults +import com.smarttoolfactory.cropper.settings.CropOutlineProperty +import com.smarttoolfactory.cropper.settings.CropProperties +import com.smarttoolfactory.cropper.settings.CropStyle + +@Composable +internal fun CropScreen( + request: ExternalCropRequest, + onFinishCanceled: () -> Unit, + onFinishWithResult: (Intent) -> Unit, + onShowSaveErrorAndCancel: () -> Unit, + modifier: Modifier = Modifier, + screenModel: CropScreenModel = hiltViewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + val currentOnFinishCanceled by rememberUpdatedState(onFinishCanceled) + val currentOnFinishWithResult by rememberUpdatedState(onFinishWithResult) + val currentOnShowSaveErrorAndCancel by rememberUpdatedState(onShowSaveErrorAndCancel) + val outputWritePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult(), + ) { result -> + screenModel.onOutputWritePermissionResult(isGranted = result.resultCode == Activity.RESULT_OK) + } + + LaunchedEffect(request, screenModel) { + screenModel.onLaunchRequest(request = request) + } + + LaunchedEffect(screenModel) { + screenModel.effects.collect { effect -> + when (effect) { + CropEffect.FinishCanceled -> currentOnFinishCanceled() + CropEffect.ShowSaveErrorAndCancel -> currentOnShowSaveErrorAndCancel() + is CropEffect.FinishWithResult -> { + currentOnFinishWithResult(effect.resultIntent) + } + + is CropEffect.RequestOutputWritePermission -> { + outputWritePermissionLauncher.launch( + IntentSenderRequest.Builder(effect.intentSender) + .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) + .build(), + ) + } + } + } + } + + BackHandler { + screenModel.onCancelClick() + } + + CropScreenContent( + modifier = modifier, + uiState = uiState, + onCancel = screenModel::onCancelClick, + onDone = screenModel::onDoneClick, + onCropRectChanged = screenModel::onCropRectChanged, + ) +} + +@Composable +internal fun CropScreenContent( + uiState: CropUiState, + onCancel: () -> Unit, + onDone: () -> Unit, + onCropRectChanged: (NormalizedCropRect) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .systemBarsPadding(), + ) { + CropScreenTopBar( + isDoneEnabled = uiState.image != null && !uiState.isSaving, + isSaving = uiState.isSaving, + onCancel = onCancel, + onDone = onDone, + ) + + CropImageStage( + cropImage = uiState.image, + aspectRatio = uiState.aspectRatio, + normalizedCropRect = uiState.normalizedCropRect, + onCropRectChanged = onCropRectChanged, + ) + } +} + +@Composable +private fun CropScreenTopBar( + isDoneEnabled: Boolean, + isSaving: Boolean, + onCancel: () -> Unit, + onDone: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onCancel, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(id = R.string.close), + ) + } + Text( + text = stringResource(id = R.string.crop), + style = MaterialTheme.typography.titleMedium, + ) + CropDoneButton( + isEnabled = isDoneEnabled, + isSaving = isSaving, + onDone = onDone, + ) + } +} + +@Composable +private fun CropDoneButton( + isEnabled: Boolean, + isSaving: Boolean, + onDone: () -> Unit, +) { + Button( + onClick = onDone, + enabled = isEnabled, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors(), + ) { + when { + isSaving -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + + else -> { + Text(text = stringResource(id = R.string.done)) + } + } + } +} + +@Composable +private fun CropImageStage( + cropImage: CropImage?, + aspectRatio: Float?, + normalizedCropRect: NormalizedCropRect?, + onCropRectChanged: (NormalizedCropRect) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + when (cropImage) { + null -> CircularProgressIndicator() + else -> { + LoadedCropImage( + cropImage = cropImage, + aspectRatio = aspectRatio, + normalizedCropRect = normalizedCropRect, + onCropRectChanged = onCropRectChanged, + ) + } + } + } +} + +@Composable +private fun LoadedCropImage( + cropImage: CropImage, + aspectRatio: Float?, + normalizedCropRect: NormalizedCropRect?, + onCropRectChanged: (NormalizedCropRect) -> Unit, +) { + val previewImageBitmap = rememberPreviewImageBitmap(cropImage = cropImage) + val cropProperties = rememberCropProperties(aspectRatio = aspectRatio) + val cropStyle = rememberCropStyle() + val initialCropRect = remember(normalizedCropRect) { + normalizedCropRect?.toAndroidRectF() + } + + ImageCropper( + modifier = Modifier.fillMaxSize(), + imageBitmap = previewImageBitmap, + contentDescription = null, + cropStyle = cropStyle, + cropProperties = cropProperties, + initialCropRect = initialCropRect, + onCropStart = {}, + onCropSuccess = {}, + onCropRectChanged = { cropRect -> + onCropRectChanged(NormalizedCropRect(rect = cropRect)) + }, + ) +} + +@Composable +private fun rememberPreviewImageBitmap(cropImage: CropImage): ImageBitmap { + return remember(cropImage.previewBitmap) { + cropImage.previewBitmap.asImageBitmap() + } +} + +@Composable +private fun rememberCropProperties(aspectRatio: Float?): CropProperties { + return remember(aspectRatio) { + CropDefaults.properties( + cropOutlineProperty = CropOutlineProperty( + outlineType = OutlineType.RoundedRect, + cropOutline = RectCropShape( + id = 0, + title = OutlineType.RoundedRect.name, + ), + ), + aspectRatio = createCropAspectRatio(aspectRatio = aspectRatio), + overlayRatio = 1f, + fixedAspectRatio = aspectRatio != null, + ) + } +} + +private fun createCropAspectRatio(aspectRatio: Float?): AspectRatio { + return when (aspectRatio) { + null -> AspectRatio.Original + else -> AspectRatio(aspectRatio) + } +} + +@Composable +private fun rememberCropStyle(): CropStyle { + val handleColor = MaterialTheme.colorScheme.tertiary + return remember(handleColor) { + CropDefaults.style( + handleColor = handleColor, + strokeWidth = 1.dp, + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropUiState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropUiState.kt new file mode 100644 index 0000000000..287cec510a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropUiState.kt @@ -0,0 +1,13 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import androidx.compose.runtime.Immutable +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect + +@Immutable +internal data class CropUiState( + val image: CropImage? = null, + val aspectRatio: Float? = null, + val normalizedCropRect: NormalizedCropRect? = null, + val isSaving: Boolean = false, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModel.kt new file mode 100644 index 0000000000..ed0fb61277 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModel.kt @@ -0,0 +1,304 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import android.content.IntentSender +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect +import com.dot.gallery.feature_node.data.repository.ExternalCropRepository +import com.dot.gallery.feature_node.data.repository.ExternalCropSaveResult +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface CropScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onLaunchRequest(request: ExternalCropRequest) + fun onCancelClick() + fun onDoneClick() + fun onCropRectChanged(normalizedRect: NormalizedCropRect) + fun onOutputWritePermissionResult(isGranted: Boolean) +} + +@HiltViewModel +internal class CropViewModel @Inject constructor( + private val repository: ExternalCropRepository, +) : ViewModel(), + CropScreenModel { + + private val effectsChannel = Channel(capacity = Channel.BUFFERED) + private val _uiState = MutableStateFlow(CropUiState()) + + private var request: ExternalCropRequest? = null + private var loadJob: Job? = null + private var saveJob: Job? = null + private var hasFinished = false + private var pendingSaveInput: CropSaveInput? = null + private var hasRequestedOutputWritePermission = false + + override val effects = effectsChannel.receiveAsFlow() + override val uiState = _uiState.asStateFlow() + + override fun onLaunchRequest(request: ExternalCropRequest) { + if (this.request == request) { + return + } + + startLaunchRequest(request = request) + loadCropImage(request = request) + } + + private fun startLaunchRequest(request: ExternalCropRequest) { + this.request = request + resetCropSession() + + _uiState.value = CropUiState( + aspectRatio = request.aspectRatio, + ) + } + + private fun resetCropSession() { + hasFinished = false + pendingSaveInput = null + hasRequestedOutputWritePermission = false + loadJob?.cancel() + saveJob?.cancel() + } + + private fun loadCropImage(request: ExternalCropRequest) { + loadJob = viewModelScope.launch { + try { + loadCropImageIntoState(request = request) + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + setLoadErrorAndFinish() + } + } + } + + private suspend fun loadCropImageIntoState(request: ExternalCropRequest) { + val image = repository.loadImage(uri = request.sourceUri) + if (image == null) { + setLoadErrorAndFinish() + return + } + + setLoadedImage( + request = request, + image = image, + ) + } + + private fun setLoadedImage(request: ExternalCropRequest, image: CropImage) { + _uiState.update { state -> + state.copy( + image = image, + normalizedCropRect = initialCropRect( + request = request, + image = image, + ), + ) + } + } + + private fun initialCropRect( + request: ExternalCropRequest, + image: CropImage, + ): NormalizedCropRect { + return NormalizedCropRect.centeredForAspectRatio( + sourceWidth = image.sourceBitmap.width, + sourceHeight = image.sourceBitmap.height, + aspectRatio = request.aspectRatio, + ) + } + + override fun onCancelClick() { + loadJob?.cancel() + saveJob?.cancel() + _uiState.update { state -> + state.copy(isSaving = false) + } + sendTerminalEffect(effect = CropEffect.FinishCanceled) + } + + override fun onDoneClick() { + if (!canStartCropSave()) { + return + } + + val saveInput = createCropSaveInput() ?: return + saveJob = launchCropSave(saveInput = saveInput) + } + + override fun onCropRectChanged(normalizedRect: NormalizedCropRect) { + if (hasFinished) { + return + } + + _uiState.update { state -> + state.copy( + normalizedCropRect = normalizedRect, + ) + } + } + + private fun canStartCropSave(): Boolean { + val currentState = _uiState.value + return currentState.image != null && + currentState.normalizedCropRect != null && + !currentState.isSaving && + saveJob?.isActive != true && + !hasFinished + } + + private fun createCropSaveInput(): CropSaveInput? { + val currentRequest = request ?: return null + val currentState = uiState.value + val image = currentState.image ?: return null + val normalizedRect = currentState.normalizedCropRect ?: return null + + return CropSaveInput( + request = currentRequest, + image = image, + normalizedRect = normalizedRect, + ) + } + + private fun launchCropSave(saveInput: CropSaveInput): Job { + return viewModelScope.launch { + saveCropResult(saveInput = saveInput) + } + } + + private suspend fun saveCropResult(saveInput: CropSaveInput) { + try { + setSaving(isSaving = true) + val resultEffect = saveCropAndCreateResultEffect(saveInput = saveInput) + setSaving(isSaving = false) + + when (resultEffect) { + // The save resumes in onOutputWritePermissionResult, so the session stays open. + is CropEffect.RequestOutputWritePermission -> sendEffect(effect = resultEffect) + else -> sendTerminalEffect(effect = resultEffect) + } + } catch (exception: CancellationException) { + setSaving(isSaving = false) + throw exception + } catch (_: Exception) { + setSaving(isSaving = false) + sendTerminalEffect(effect = CropEffect.ShowSaveErrorAndCancel) + } + } + + private suspend fun saveCropAndCreateResultEffect(saveInput: CropSaveInput): CropEffect { + return createCropResultEffect( + saveInput = saveInput, + saveResult = repository.saveCropResult( + request = saveInput.request, + image = saveInput.image, + normalizedRect = saveInput.normalizedRect, + ), + ) + } + + private fun createCropResultEffect( + saveInput: CropSaveInput, + saveResult: ExternalCropSaveResult, + ): CropEffect { + return when (saveResult) { + is ExternalCropSaveResult.Saved -> { + CropEffect.FinishWithResult(resultIntent = saveResult.resultIntent) + } + + is ExternalCropSaveResult.OutputPermissionRequired -> { + createOutputWritePermissionEffect( + saveInput = saveInput, + intentSender = saveResult.intentSender, + ) + } + + ExternalCropSaveResult.Failed -> CropEffect.ShowSaveErrorAndCancel + } + } + + /** + * Only ask once per session: if the save still cannot write after the user has answered, asking + * again would loop the dialog. + */ + private fun createOutputWritePermissionEffect( + saveInput: CropSaveInput, + intentSender: IntentSender, + ): CropEffect { + if (hasRequestedOutputWritePermission) { + return CropEffect.ShowSaveErrorAndCancel + } + + hasRequestedOutputWritePermission = true + pendingSaveInput = saveInput + + return CropEffect.RequestOutputWritePermission(intentSender = intentSender) + } + + override fun onOutputWritePermissionResult(isGranted: Boolean) { + val saveInput = pendingSaveInput ?: return + pendingSaveInput = null + + if (!isGranted) { + sendTerminalEffect(effect = CropEffect.FinishCanceled) + return + } + + saveJob = launchCropSave(saveInput = saveInput) + } + + private fun setSaving(isSaving: Boolean) { + _uiState.update { state -> + state.copy(isSaving = isSaving) + } + } + + private fun setLoadErrorAndFinish() { + _uiState.update { state -> + state.copy( + image = null, + isSaving = false, + ) + } + + sendTerminalEffect(effect = CropEffect.FinishCanceled) + } + + private fun sendTerminalEffect(effect: CropEffect) { + if (hasFinished) { + return + } + + hasFinished = true + sendEffect(effect = effect) + } + + private fun sendEffect(effect: CropEffect) { + viewModelScope.launch { + effectsChannel.send(element = effect) + } + } +} + +private data class CropSaveInput( + val request: ExternalCropRequest, + val image: CropImage, + val normalizedRect: NormalizedCropRect, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt index 1fba5ddae7..274ecfa029 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt @@ -2,16 +2,15 @@ package com.dot.gallery.feature_node.presentation.exif import android.os.Build import android.os.Environment +import android.provider.MediaStore import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -28,7 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -45,9 +44,15 @@ import kotlinx.coroutines.launch fun AddAlbumSheet( sheetState: AppBottomSheetState, onFinish: (albumName: String) -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, ) { var newAlbum: String by remember { mutableStateOf("") } + val context = LocalContext.current + val hasFullMediaAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.isExternalStorageManager() || MediaStore.canManageMedia(context) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else true val scope = rememberCoroutineScope { Dispatchers.Main } if (sheetState.isVisible) { @@ -84,11 +89,8 @@ fun AddAlbumSheet( .fillMaxWidth() ) - val isStorageManager = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true - } - val location = remember(isStorageManager, newAlbum) { - if (!isStorageManager) { + val location = remember(hasFullMediaAccess, newAlbum) { + if (!hasFullMediaAccess) { "Pictures/$newAlbum" } else { newAlbum @@ -149,4 +151,4 @@ fun AddAlbumSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt index 88a2d22a1a..ae13ef43ad 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt @@ -2,6 +2,7 @@ package com.dot.gallery.feature_node.presentation.exif import android.os.Build import android.os.Environment +import android.provider.MediaStore import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -18,18 +20,23 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums -import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupComponent +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,12 +45,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.SecureFlagPolicy import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation @@ -51,49 +62,49 @@ import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.SecurityInfoSheet -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.volume +import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.volume import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent +import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupComponent import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState +import com.dot.gallery.feature_node.presentation.security.rememberBiometricState import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState +import com.dot.gallery.ui.theme.GalleryTheme +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CopyMediaSheet( +fun CopyMediaSheet( sheetState: AppBottomSheetState, albumsState: State, mediaList: List, onFinish: () -> Unit, ) { val scope = rememberCoroutineScope() + val context = LocalContext.current + val hasFullMediaAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.isExternalStorageManager() || MediaStore.canManageMedia(context) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else true val viewModel: CopyMediaViewModel = hiltViewModel() - val progress by viewModel.progress.collectAsState() - val isActive by viewModel.isActive.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val progress = (uiState as? CopyMediaUiState.Copying)?.progress ?: 0f + val isSelecting = uiState is CopyMediaUiState.Selecting val newAlbumSheetState = rememberAppBottomSheetState() val securitySheetState = rememberAppBottomSheetState() var pendingLockedAlbumPath by remember { mutableStateOf(null) } - val mutex = Mutex() + var searchQuery by remember { mutableStateOf("") } fun copyMedia(path: String) { - scope.launch(Dispatchers.IO) { - mutex.withLock { - viewModel.enqueueCopy(*mediaList.map { it to path }.toTypedArray()) { - scope.launch { - sheetState.show() - } - } - } - } + viewModel.enqueueCopy(*mediaList.map { media -> media to path }.toTypedArray()) } val biometricState = rememberBiometricState( @@ -110,11 +121,22 @@ fun CopyMediaSheet( } ) - LaunchedEffect(isActive) { - if (isActive) { - sheetState.show() - } else { - sheetState.hide() + LaunchedEffect(uiState) { + when (uiState) { + is CopyMediaUiState.Copying, + is CopyMediaUiState.Failed, + CopyMediaUiState.StatusUnavailable, + -> { + sheetState.show() + } + + CopyMediaUiState.Succeeded -> { + sheetState.hide() + viewModel.onResultHandled() + onFinish() + } + + CopyMediaUiState.Selecting -> Unit } } @@ -123,8 +145,8 @@ fun CopyMediaSheet( enter = enterAnimation, exit = exitAnimation ) { - val shouldDismiss by rememberedDerivedState(progress) { - progress == 0f + val shouldDismiss by rememberedDerivedState(uiState) { + uiState !is CopyMediaUiState.Copying } val prop = ModalBottomSheetProperties( securePolicy = SecureFlagPolicy.Inherit, @@ -134,11 +156,11 @@ fun CopyMediaSheet( sheetState = sheetState.sheetState, onDismissRequest = { scope.launch { - if (progress == 0f) { - sheetState.hide() - } else { - sheetState.show() - } + handleDismissRequest( + sheetState = sheetState, + uiState = uiState, + onResultHandled = viewModel::onResultHandled, + ) } }, properties = prop, @@ -150,6 +172,7 @@ fun CopyMediaSheet( modifier = Modifier .wrapContentHeight() .navigationBarsPadding() + .imePadding() .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -165,7 +188,40 @@ fun CopyMediaSheet( ) AnimatedVisibility( - visible = progress > 0f, + visible = isSelecting, + enter = enterAnimation, + exit = exitAnimation + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = { Text(stringResource(R.string.search_albums)) }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = stringResource(R.string.search) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp) + ) + } + + AnimatedVisibility( + visible = uiState is CopyMediaUiState.Copying, modifier = Modifier .padding(32.dp) .align(Alignment.CenterHorizontally), @@ -191,21 +247,45 @@ fun CopyMediaSheet( val albumSize by rememberAlbumGridSize() AnimatedVisibility( - visible = progress == 0f, + visible = isSelecting, enter = enterAnimation, exit = exitAnimation ) { - val groups = albumsState.value.albumGroups - val groupedAlbumIds = remember(groups) { - groups.flatMap { g -> g.albums.map { it.id } }.toSet() + val allGroups = albumsState.value.albumGroups + val groupedAlbumIds = remember(allGroups) { + allGroups.flatMap { g -> g.albums.map { it.id } }.toSet() } - val ungroupedAlbums = remember(albumsState.value.albums, groupedAlbumIds) { + val allUngroupedAlbums = remember(albumsState.value.albums, groupedAlbumIds) { albumsState.value.albums.filter { it.id !in groupedAlbumIds } } var selectedGroup by remember { mutableStateOf(null) } // Keep selectedGroup in sync with latest data val liveSelectedGroup = selectedGroup?.let { sel -> - groups.find { it.group.id == sel.group.id } + allGroups.find { it.group.id == sel.group.id } + } + + val query = searchQuery.trim() + val filteredGroups = remember(allGroups, query) { + if (query.isEmpty()) allGroups + else allGroups.mapNotNull { g -> + val matched = g.albums.filter { + it.label.contains(other = query, ignoreCase = true) + } + if (matched.isNotEmpty()) g.copy(albums = matched) + else if (g.group.label.contains(other = query, ignoreCase = true)) g + else null + } + } + val filteredUngroupedAlbums = remember(allUngroupedAlbums, query) { + if (query.isEmpty()) allUngroupedAlbums + else allUngroupedAlbums.filter { + it.label.contains(other = query, ignoreCase = true) + } + } + val filteredGroupAlbums = remember(liveSelectedGroup, query) { + val albums = liveSelectedGroup?.albums ?: emptyList() + if (query.isEmpty()) albums + else albums.filter { it.label.contains(other = query, ignoreCase = true) } } LazyVerticalGrid( @@ -228,11 +308,14 @@ fun CopyMediaSheet( ) { PickerGroupBackHeader( group = liveSelectedGroup, - onBack = { selectedGroup = null } + onBack = { + selectedGroup = null + searchQuery = "" + } ) } items( - items = liveSelectedGroup.albums, + items = filteredGroupAlbums, key = { item -> "group_album_${item.id}" } ) { item -> val mediaVolume = (mediaList.firstOrNull()?.volume ?: item.volume) @@ -243,45 +326,44 @@ fun CopyMediaSheet( "Android/media/", "allow" ) ?: albumOwnership - val isStorageManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true AlbumComponent( modifier = Modifier.animateItem(), album = item, - isEnabled = isStorageManager || (item.volume == mediaVolume + isEnabled = hasFullMediaAccess || (item.volume == mediaVolume && albumOwnership == "allow" - && mediaOwnership == "allow" - && (item.relativePath.contains("Pictures") - || item.relativePath.contains("DCIM"))), + && mediaOwnership == "allow"), onItemClick = { album -> if (album.isLocked) { if (!biometricState.isSupported) { scope.launch { securitySheetState.show() } } else { - pendingLockedAlbumPath = album.relativePath + pendingLockedAlbumPath = album.absolutePath biometricState.authenticate() } } else { - copyMedia(album.relativePath) + copyMedia(album.absolutePath) } } ) } } else { // Main view: New Album + groups + ungrouped albums - item { - AlbumComponent( - album = Album.NewAlbum, - isEnabled = true, - onItemClick = { - scope.launch(Dispatchers.Main) { - newAlbumSheetState.show() + if (query.isEmpty()) { + item { + AlbumComponent( + album = Album.NewAlbum, + isEnabled = true, + onItemClick = { + scope.launch(Dispatchers.Main) { + newAlbumSheetState.show() + } } - } - ) + ) + } } items( - items = groups, + items = filteredGroups, key = { group -> "group_${group.group.id}" } ) { group -> AlbumGroupComponent( @@ -292,7 +374,7 @@ fun CopyMediaSheet( } items( - items = ungroupedAlbums, + items = filteredUngroupedAlbums, key = { item -> item.toString() } ) { item -> val mediaVolume = (mediaList.firstOrNull()?.volume ?: item.volume) @@ -303,24 +385,21 @@ fun CopyMediaSheet( "Android/media/", "allow" ) ?: albumOwnership - val isStorageManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true AlbumComponent( album = item, - isEnabled = isStorageManager || (item.volume == mediaVolume + isEnabled = hasFullMediaAccess || (item.volume == mediaVolume && albumOwnership == "allow" - && mediaOwnership == "allow" - && (item.relativePath.contains("Pictures") - || item.relativePath.contains("DCIM"))), + && mediaOwnership == "allow"), onItemClick = { album -> if (album.isLocked) { if (!biometricState.isSupported) { scope.launch { securitySheetState.show() } } else { - pendingLockedAlbumPath = album.relativePath + pendingLockedAlbumPath = album.absolutePath biometricState.authenticate() } } else { - copyMedia(album.relativePath) + copyMedia(album.absolutePath) } } ) @@ -328,6 +407,41 @@ fun CopyMediaSheet( } } } + + val failedState = uiState as? CopyMediaUiState.Failed + AnimatedVisibility( + visible = failedState != null, + enter = enterAnimation, + exit = exitAnimation, + ) { + failedState?.let { state -> + CopyMediaFailureContent( + copiedCount = state.copiedCount, + failedCount = state.failedCount, + onClose = { + scope.launch { + sheetState.hide() + viewModel.onResultHandled() + } + }, + ) + } + } + + AnimatedVisibility( + visible = uiState == CopyMediaUiState.StatusUnavailable, + enter = enterAnimation, + exit = exitAnimation, + ) { + CopyMediaStatusUnavailableContent( + onClose = { + scope.launch { + sheetState.hide() + viewModel.onResultHandled() + } + }, + ) + } } } } @@ -337,7 +451,7 @@ fun CopyMediaSheet( AddAlbumSheet( sheetState = newAlbumSheetState, onFinish = { newAlbum -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) { + if (hasFullMediaAccess) { copyMedia(newAlbum) } else { copyMedia("Pictures/$newAlbum") @@ -351,4 +465,141 @@ fun CopyMediaSheet( } } ) -} \ No newline at end of file +} + +private suspend fun handleDismissRequest( + sheetState: AppBottomSheetState, + uiState: CopyMediaUiState, + onResultHandled: () -> Unit, +) { + when (uiState) { + is CopyMediaUiState.Copying -> { + sheetState.show() + } + + is CopyMediaUiState.Failed, + CopyMediaUiState.StatusUnavailable, + -> { + sheetState.hide() + onResultHandled() + } + + CopyMediaUiState.Selecting, + CopyMediaUiState.Succeeded, + -> { + sheetState.hide() + } + } +} + +@Composable +private fun CopyMediaStatusUnavailableContent( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.media_copy_status_unavailable_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.media_copy_status_unavailable_guidance), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.close)) + } + } +} + +@Composable +private fun CopyMediaFailureContent( + copiedCount: Int, + failedCount: Int, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.media_copy_failed_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + ) + if (copiedCount > 0) { + Text( + text = pluralStringResource( + R.plurals.media_copy_succeeded_count, + copiedCount, + copiedCount, + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + Text( + text = pluralStringResource( + R.plurals.media_copy_failed_count, + failedCount, + failedCount, + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource( + when { + copiedCount > 0 -> R.string.media_copy_partial_failure_guidance + else -> R.string.media_copy_failure_guidance + }, + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.close)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CopyMediaPartialFailurePreview() { + GalleryTheme { + CopyMediaFailureContent( + copiedCount = 2, + failedCount = 1, + onClose = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CopyMediaStatusUnavailablePreview() { + GalleryTheme { + CopyMediaStatusUnavailableContent(onClose = {}) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModel.kt index 7d278475c1..13a151f1e0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModel.kt @@ -1,48 +1,172 @@ package com.dot.gallery.feature_node.presentation.exif +import androidx.compose.runtime.Immutable +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.dot.gallery.core.workers.copyMedia -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.core.workers.MediaCopyBatch +import com.dot.gallery.core.workers.MediaCopyBatchStatus +import com.dot.gallery.core.workers.MediaCopyRequest +import com.dot.gallery.core.workers.MediaCopyScheduler +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val CURRENT_BATCH_ITEM_COUNT_KEY = "current_copy_batch_item_count" +private const val CURRENT_BATCH_TAG_KEY = "current_copy_batch_tag" +private const val CURRENT_BATCH_WORK_COUNT_KEY = "current_copy_batch_work_count" +private const val LEGACY_BATCH_ENQUEUE_CONFIRMED_KEY = "current_copy_batch_enqueue_confirmed" + +@Immutable +internal sealed interface CopyMediaUiState { + + data object Selecting : CopyMediaUiState + + data class Copying(val progress: Float) : CopyMediaUiState + + data object Succeeded : CopyMediaUiState + + data class Failed( + val copiedCount: Int, + val failedCount: Int, + ) : CopyMediaUiState + + data object StatusUnavailable : CopyMediaUiState +} @HiltViewModel -class CopyMediaViewModel @Inject constructor( - private val workManager: WorkManager +internal class CopyMediaViewModel @Inject constructor( + private val mediaCopyScheduler: MediaCopyScheduler, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val workInfosFlow = workManager.getWorkInfosByTagFlow("MediaCopyWorker") - - val isActive: StateFlow = workInfosFlow - .map { list -> list.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - - val progress: StateFlow = workInfosFlow - .map { list -> - // Only consider work that is currently running or enqueued - val activeWork = list.filter { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } - - // If no active work, return 0 to show the album selection UI - if (activeWork.isEmpty()) return@map 0f - - val progressValues = activeWork.map { it.progress.getInt("progress", 0) } - val avg = if (progressValues.isNotEmpty()) progressValues.sum() / progressValues.size else 0 - avg.coerceIn(0, 100) / 100f - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f) - - fun enqueueCopy(vararg sets: Pair, onStarted: () -> Unit = {}) { - if (sets.isEmpty()) return - // Prune old finished work before starting new work - workManager.pruneWork() - workManager.copyMedia(*sets) - onStarted() + private val _uiState = MutableStateFlow(CopyMediaUiState.Selecting) + private var enqueueJob: Job? = null + private var observationJob: Job? = null + + val uiState: StateFlow = _uiState.asStateFlow() + + init { + restoredBatch()?.let { batch -> + observeBatch(batch = batch) + } + } + + fun enqueueCopy(vararg sets: Pair) { + if (sets.isEmpty() || _uiState.value is CopyMediaUiState.Copying) { + return + } + + val requests = sets.map { (media, path) -> + MediaCopyRequest( + sourceUri = media.getUri(), + destinationPath = path, + ) + } + val batch = mediaCopyScheduler.prepareBatch(requests = requests) + saveBatch(batch = batch) + _uiState.value = CopyMediaUiState.Copying(progress = 0f) + enqueueJob = viewModelScope.launch { + try { + mediaCopyScheduler.enqueue( + batch = batch, + requests = requests, + ) + observeBatch(batch = batch) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + _uiState.value = CopyMediaUiState.StatusUnavailable + } + } + } + + fun onResultHandled() { + if (_uiState.value is CopyMediaUiState.Copying) { + return + } + + enqueueJob?.cancel() + enqueueJob = null + observationJob?.cancel() + observationJob = null + clearSavedBatch() + _uiState.value = CopyMediaUiState.Selecting + } + + private fun observeBatch(batch: MediaCopyBatch) { + observationJob?.cancel() + _uiState.value = CopyMediaUiState.Copying(progress = 0f) + observationJob = viewModelScope.launch { + try { + val terminalStatus = mediaCopyScheduler.observe(batch = batch) + .onEach { status -> + if (status is MediaCopyBatchStatus.Copying) { + _uiState.value = CopyMediaUiState.Copying(progress = status.progress) + } + } + .firstOrNull { status -> status !is MediaCopyBatchStatus.Copying } + + _uiState.value = terminalStatus?.toUiState() + ?: CopyMediaUiState.StatusUnavailable + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + _uiState.value = CopyMediaUiState.StatusUnavailable + } + } + } + + private fun saveBatch(batch: MediaCopyBatch) { + savedStateHandle[CURRENT_BATCH_TAG_KEY] = batch.tag + savedStateHandle[CURRENT_BATCH_WORK_COUNT_KEY] = batch.workRequestCount + savedStateHandle[CURRENT_BATCH_ITEM_COUNT_KEY] = batch.itemCount + } + + private fun restoredBatch(): MediaCopyBatch? { + val tag = savedStateHandle.get(CURRENT_BATCH_TAG_KEY) + val workRequestCount = savedStateHandle.get(CURRENT_BATCH_WORK_COUNT_KEY) + val itemCount = savedStateHandle.get(CURRENT_BATCH_ITEM_COUNT_KEY) + + return when { + tag == null || workRequestCount == null || itemCount == null -> null + workRequestCount <= 0 || itemCount <= 0 -> null + else -> MediaCopyBatch( + tag = tag, + workRequestCount = workRequestCount, + itemCount = itemCount, + ) + } + } + + private fun clearSavedBatch() { + savedStateHandle.remove(CURRENT_BATCH_TAG_KEY) + savedStateHandle.remove(CURRENT_BATCH_WORK_COUNT_KEY) + savedStateHandle.remove(CURRENT_BATCH_ITEM_COUNT_KEY) + savedStateHandle.remove(LEGACY_BATCH_ENQUEUE_CONFIRMED_KEY) + } +} + +private fun MediaCopyBatchStatus.toUiState(): CopyMediaUiState { + return when (this) { + is MediaCopyBatchStatus.Copying -> CopyMediaUiState.Copying(progress = progress) + is MediaCopyBatchStatus.Finished -> when { + successful -> CopyMediaUiState.Succeeded + else -> CopyMediaUiState.Failed( + copiedCount = copiedCount, + failedCount = failedCount, + ) + } + + MediaCopyBatchStatus.Unavailable -> CopyMediaUiState.StatusUnavailable } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt index 698180dcc5..d41509aff4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt @@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -40,12 +39,13 @@ import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.launchWriteRequest +import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.toastError import com.dot.gallery.feature_node.presentation.util.writeRequest @@ -109,6 +109,7 @@ fun MetadataEditSheet( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() + .imePadding() .verticalScroll(rememberScrollState()) ) { Text( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataViewViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataViewViewModel.kt index c565de8df2..cbcac3e715 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataViewViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataViewViewModel.kt @@ -5,16 +5,21 @@ package com.dot.gallery.feature_node.presentation.exif +import android.content.ContentUris +import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.dot.gallery.core.Settings import com.dot.gallery.core.sandbox.IsolatedMetadataParser import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -import javax.inject.Inject data class MetadataDirectory( val name: String, @@ -33,7 +38,8 @@ data class MetadataViewState( @HiltViewModel class MetadataViewViewModel @Inject constructor( - private val isolatedParser: IsolatedMetadataParser + private val isolatedParser: IsolatedMetadataParser, + @param:ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(MetadataViewState()) @@ -44,7 +50,15 @@ class MetadataViewViewModel @Inject constructor( _state.value = MetadataViewState(isLoading = true) val uri = Uri.parse(mediaUri) val directories = runCatching { - isolatedParser.parseRawMetadata(uri, isVideo) + val mode = Settings.Security.getMetadataIsolationMode(appContext) + .firstOrNull() ?: Settings.Security.DEFAULT_METADATA_ISOLATION_MODE + val usePerFile = mode != Settings.Security.METADATA_ISOLATION_SHARED + if (usePerFile) { + val mediaId = ContentUris.parseId(uri) + isolatedParser.parseRawMetadataPerFile(uri, isVideo, mediaId) + } else { + isolatedParser.parseRawMetadata(uri, isVideo) + } }.getOrElse { emptyList() } _state.value = MetadataViewState( isLoading = false, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt index 1fb5ac0a88..75bc23dcf2 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt @@ -3,6 +3,7 @@ package com.dot.gallery.feature_node.presentation.exif import android.media.MediaScannerConnection import android.os.Build import android.os.Environment +import android.provider.MediaStore import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -19,12 +21,17 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums -import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupComponent +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -48,18 +55,20 @@ import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.SecurityInfoSheet -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.volume +import com.dot.gallery.feature_node.domain.model.AlbumGroupWithAlbums import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.volume import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent +import com.dot.gallery.feature_node.presentation.albums.components.AlbumGroupComponent +import com.dot.gallery.feature_node.presentation.security.rememberBiometricState import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.launchWriteRequest import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.toastError import com.dot.gallery.feature_node.presentation.util.writeRequest -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -75,6 +84,11 @@ fun MoveMediaSheet( ) { val handler = LocalMediaHandler.current val context = LocalContext.current + val hasFullMediaAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.isExternalStorageManager() || MediaStore.canManageMedia(context) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else true val toastError = toastError() val scope = rememberCoroutineScope() @@ -84,15 +98,17 @@ fun MoveMediaSheet( val newAlbumSheetState = rememberAppBottomSheetState() val securitySheetState = rememberAppBottomSheetState() var pendingLockedAlbumPath by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } val doMove: () -> Unit = { scope.launch { val done = async { mediaList.forEachIndexed { index, it -> if (handler.moveMedia(media = it, newPath = newPath)) { + val movedFilePath = newPath.trimEnd('/') + "/" + it.label MediaScannerConnection.scanFile( context, - arrayOf(newPath), + arrayOf(movedFilePath), arrayOf(it.mimeType), null ) @@ -104,6 +120,9 @@ fun MoveMediaSheet( return@async true } if (done.await()) { + context.contentResolver.notifyChange( + MediaStore.Files.getContentUri("external"), null + ) sheetState.hide() onFinish() } else { @@ -153,6 +172,7 @@ fun MoveMediaSheet( Column( modifier = Modifier .wrapContentHeight() + .imePadding() .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -167,6 +187,39 @@ fun MoveMediaSheet( .fillMaxWidth() ) + AnimatedVisibility( + visible = progress == 0f, + enter = Constants.Animation.enterAnimation, + exit = Constants.Animation.exitAnimation + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = { Text(stringResource(R.string.search_albums)) }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = stringResource(R.string.search) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp) + ) + } + AnimatedVisibility( visible = progress > 0f, modifier = Modifier @@ -192,17 +245,41 @@ fun MoveMediaSheet( enter = Constants.Animation.enterAnimation, exit = Constants.Animation.exitAnimation ) { - val groups = albumState.value.albumGroups - val groupedAlbumIds = remember(groups) { - groups.flatMap { g -> g.albums.map { it.id } }.toSet() + val allGroups = albumState.value.albumGroups + val groupedAlbumIds = remember(allGroups) { + allGroups.flatMap { g -> g.albums.map { it.id } }.toSet() } - val ungroupedAlbums = remember(albumState.value.albums, groupedAlbumIds) { + val allUngroupedAlbums = remember(albumState.value.albums, groupedAlbumIds) { albumState.value.albums.filter { it.id !in groupedAlbumIds } } var selectedGroup by remember { mutableStateOf(null) } // Keep selectedGroup in sync with latest data val liveSelectedGroup = selectedGroup?.let { sel -> - groups.find { it.group.id == sel.group.id } + allGroups.find { it.group.id == sel.group.id } + } + + val query = searchQuery.trim() + val filteredGroups = remember(allGroups, query) { + if (query.isEmpty()) allGroups + else allGroups.mapNotNull { g -> + val matched = g.albums.filter { + it.label.contains(other = query, ignoreCase = true) + } + if (matched.isNotEmpty()) g.copy(albums = matched) + else if (g.group.label.contains(other = query, ignoreCase = true)) g + else null + } + } + val filteredUngroupedAlbums = remember(allUngroupedAlbums, query) { + if (query.isEmpty()) allUngroupedAlbums + else allUngroupedAlbums.filter { + it.label.contains(other = query, ignoreCase = true) + } + } + val filteredGroupAlbums = remember(liveSelectedGroup, query) { + val albums = liveSelectedGroup?.albums ?: emptyList() + if (query.isEmpty()) albums + else albums.filter { it.label.contains(other = query, ignoreCase = true) } } LazyVerticalGrid( @@ -225,11 +302,14 @@ fun MoveMediaSheet( ) { PickerGroupBackHeader( group = liveSelectedGroup, - onBack = { selectedGroup = null } + onBack = { + selectedGroup = null + searchQuery = "" + } ) } items( - items = liveSelectedGroup.albums, + items = filteredGroupAlbums, key = { item -> "group_album_${item.id}" } ) { item -> val mediaVolume = (mediaList.firstOrNull()?.volume ?: item.volume) @@ -241,46 +321,45 @@ fun MoveMediaSheet( "allow" ) ?: albumOwnership val mediaAlbum = mediaList.firstOrNull()?.albumLabel ?: item.label - val isStorageManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true AlbumComponent( modifier = Modifier.animateItem(), album = item, - isEnabled = isStorageManager || (item.volume == mediaVolume + isEnabled = hasFullMediaAccess || (item.volume == mediaVolume && albumOwnership == "allow" && mediaOwnership == "allow" - && item.label != mediaAlbum - && (item.relativePath.contains("Pictures") - || item.relativePath.contains("DCIM"))), + && item.label != mediaAlbum), onItemClick = { album -> if (album.isLocked) { if (!biometricState.isSupported) { scope.launch { securitySheetState.show() } } else { - pendingLockedAlbumPath = album.relativePath + pendingLockedAlbumPath = album.absolutePath biometricState.authenticate() } } else { - startMove(album.relativePath) + startMove(album.absolutePath) } } ) } } else { // Main view: New Album + groups + ungrouped albums - item { - AlbumComponent( - album = Album.NewAlbum, - isEnabled = true, - onItemClick = { - scope.launch(Dispatchers.Main) { - newAlbumSheetState.show() + if (query.isEmpty()) { + item { + AlbumComponent( + album = Album.NewAlbum, + isEnabled = true, + onItemClick = { + scope.launch(Dispatchers.Main) { + newAlbumSheetState.show() + } } - } - ) + ) + } } items( - items = groups, + items = filteredGroups, key = { group -> "group_${group.group.id}" } ) { group -> AlbumGroupComponent( @@ -291,7 +370,7 @@ fun MoveMediaSheet( } items( - items = ungroupedAlbums, + items = filteredUngroupedAlbums, key = { item -> item.toString() } ) { item -> val mediaVolume = (mediaList.firstOrNull()?.volume ?: item.volume) @@ -303,25 +382,22 @@ fun MoveMediaSheet( "allow" ) ?: albumOwnership val mediaAlbum = mediaList.firstOrNull()?.albumLabel ?: item.label - val isStorageManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager() else true AlbumComponent( album = item, - isEnabled = isStorageManager || (item.volume == mediaVolume + isEnabled = hasFullMediaAccess || (item.volume == mediaVolume && albumOwnership == "allow" && mediaOwnership == "allow" - && item.label != mediaAlbum - && (item.relativePath.contains("Pictures") - || item.relativePath.contains("DCIM"))), + && item.label != mediaAlbum), onItemClick = { album -> if (album.isLocked) { if (!biometricState.isSupported) { scope.launch { securitySheetState.show() } } else { - pendingLockedAlbumPath = album.relativePath + pendingLockedAlbumPath = album.absolutePath biometricState.authenticate() } } else { - startMove(album.relativePath) + startMove(album.absolutePath) } } ) @@ -339,7 +415,7 @@ fun MoveMediaSheet( sheetState = newAlbumSheetState, onFinish = { newAlbum -> scope.launch(Dispatchers.Main) { - newPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) newAlbum else "Pictures/$newAlbum" + newPath = if (hasFullMediaAccess) newAlbum else "Pictures/$newAlbum" request.launchWriteRequest( mediaList.writeRequest(context.contentResolver), doMove @@ -355,4 +431,3 @@ fun MoveMediaSheet( } ) } - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt index 5c8b2392be..f7181b882e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.State import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.Constants.Target.TARGET_FAVORITES -import com.dot.gallery.feature_node.domain.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.Media.UriMedia import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.common.MediaScreen diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt index 9175c8b86c..ff346b5e92 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt @@ -21,7 +21,7 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.util.selectedMedia import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/HelpScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/HelpScreen.kt index 010b4a8afc..6f923f21bc 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/HelpScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/HelpScreen.kt @@ -8,11 +8,9 @@ package com.dot.gallery.feature_node.presentation.help import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api @@ -24,12 +22,12 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.LocalEventHandler @@ -37,7 +35,6 @@ import com.dot.gallery.core.Position import com.dot.gallery.core.SettingsEntity import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.presentation.help.components.WhatsNewHeroCard import com.dot.gallery.feature_node.presentation.help.data.HelpCategory import com.dot.gallery.feature_node.presentation.help.data.HelpRepository import com.dot.gallery.feature_node.presentation.help.data.displayTitle @@ -52,7 +49,6 @@ fun HelpScreen() { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val eventHandler = LocalEventHandler.current - val currentRelease = remember { HelpRepository.getCurrentRelease() } val getStartedCategories = remember { HelpRepository.getGetStartedCategories() } val makeMostCategories = remember { HelpRepository.getMakeMostCategories() } val exploreMoreCategories = remember { HelpRepository.getExploreMoreCategories() } @@ -79,16 +75,6 @@ fun HelpScreen() { bottom = padding.calculateBottomPadding() + 16.dp ), ) { - // What's New Hero Card - item { - WhatsNewHeroCard( - versionName = currentRelease.versionName, - onClick = { eventHandler.navigate(Screen.WhatsNewScreen()) }, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(Modifier.height(16.dp)) - } - // Get Started Section item { val headerTitle = stringResource(R.string.help_get_started) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialCategoryScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialCategoryScreen.kt index efc04d102f..cc90b99a57 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialCategoryScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialCategoryScreen.kt @@ -22,11 +22,11 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.navigate diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialDetailScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialDetailScreen.kt index 501133c138..e83010a35a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialDetailScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/TutorialDetailScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,6 +41,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.LocalEventHandler diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/WhatsNewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/WhatsNewScreen.kt deleted file mode 100644 index a71b5c4979..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/WhatsNewScreen.kt +++ /dev/null @@ -1,232 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.help - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Article -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.presentation.help.data.HelpRepository -import com.dot.gallery.feature_node.presentation.help.data.ReleaseHighlight -import com.dot.gallery.feature_node.presentation.help.data.ReleaseNotes -import com.dot.gallery.feature_node.presentation.util.PreviewHost -import com.dot.gallery.feature_node.presentation.util.Screen - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WhatsNewScreen() { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val eventHandler = LocalEventHandler.current - - val allReleases = remember { HelpRepository.getAllReleases() } - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - title = { Text(stringResource(R.string.help_whats_new)) }, - navigationIcon = { NavigationBackButton() }, - scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface - ) - ) - } - ) { padding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .widthIn(max = 640.dp), - contentPadding = PaddingValues( - start = padding.calculateStartPadding(LocalLayoutDirection.current) + 16.dp, - end = padding.calculateEndPadding(LocalLayoutDirection.current) + 16.dp, - top = padding.calculateTopPadding() + 8.dp, - bottom = padding.calculateBottomPadding() + 16.dp - ), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - items(allReleases, key = { it.versionCode }) { release -> - ReleaseCard( - release = release, - isLatest = release == allReleases.first(), - onHighlightClick = { highlight -> - highlight.tipId?.let { id -> - eventHandler.navigate(Screen.TutorialDetailScreen.tipId(id)) - } - } - ) - } - } - } -} - -@Composable -private fun ReleaseCard( - release: ReleaseNotes, - isLatest: Boolean, - onHighlightClick: (ReleaseHighlight) -> Unit, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (isLatest) - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - else - MaterialTheme.colorScheme.surfaceContainer - ), - shape = MaterialTheme.shapes.extraLarge - ) { - Column(modifier = Modifier.padding(20.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "v${release.versionName}", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - if (isLatest) { - Text( - text = stringResource(R.string.help_latest), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.primary) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) - } - } - Text( - text = release.releaseDate, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - if (release.highlights.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - release.highlights.forEachIndexed { index, highlight -> - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - } - HighlightItem( - highlight = highlight, - onClick = { onHighlightClick(highlight) } - ) - } - } - } - } -} - -@Composable -private fun HighlightItem( - highlight: ReleaseHighlight, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Card( - onClick = onClick, - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0f)), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { - Row( - modifier = Modifier.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center - ) { - val icon = highlight.icon.vector ?: Icons.AutoMirrored.Outlined.Article - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(20.dp) - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(highlight.title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(highlight.description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun WhatsNewScreenPreview() { - PreviewHost { - WhatsNewScreen() - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewHeroCard.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewHeroCard.kt deleted file mode 100644 index 990288fddf..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewHeroCard.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.help.components - -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.NewReleases -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.PreviewHost - -@Composable -fun WhatsNewHeroCard( - versionName: String, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val colorPrimary = MaterialTheme.colorScheme.primaryContainer - val colorTertiary = MaterialTheme.colorScheme.tertiaryContainer - - val transition = rememberInfiniteTransition() - val fraction by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 8_000), - repeatMode = RepeatMode.Reverse - ) - ) - val cornerRadius = 24.dp - - Column( - modifier = modifier - .widthIn(max = 600.dp) - .fillMaxWidth() - .drawWithCache { - val cx = size.width - size.width * fraction - val cy = size.height * fraction - - val gradient = Brush.radialGradient( - colors = listOf(colorPrimary, colorTertiary), - center = Offset(cx, cy), - radius = 800f - ) - - onDrawBehind { - drawRoundRect( - brush = gradient, - cornerRadius = CornerRadius( - cornerRadius.toPx(), - cornerRadius.toPx() - ) - ) - } - } - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f), - shape = RoundedCornerShape(cornerRadius) - ) - .clip(RoundedCornerShape(cornerRadius)) - .clickable(onClick = onClick) - .padding(all = 24.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Outlined.NewReleases, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(32.dp) - ) - Text( - text = "v$versionName", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(50) - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(R.string.help_whats_new), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.help_whats_new_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun WhatsNewHeroCardPreview() { - PreviewHost { - WhatsNewHeroCard( - versionName = "4.2.0", - onClick = {} - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewTimelineCard.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewTimelineCard.kt deleted file mode 100644 index f97925eb64..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/components/WhatsNewTimelineCard.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.help.components - -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.NewReleases -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.PreviewHost - -@Composable -fun WhatsNewTimelineCard( - versionName: String, - onExplore: () -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val colorPrimary = MaterialTheme.colorScheme.primaryContainer - val colorTertiary = MaterialTheme.colorScheme.tertiaryContainer - - val transition = rememberInfiniteTransition() - val fraction by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 8_000), - repeatMode = RepeatMode.Reverse - ) - ) - val cornerRadius = 24.dp - - Column( - modifier = modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .widthIn(max = 600.dp) - .fillMaxWidth() - .drawWithCache { - val cx = size.width - size.width * fraction - val cy = size.height * fraction - - val gradient = Brush.radialGradient( - colors = listOf(colorPrimary, colorTertiary), - center = Offset(cx, cy), - radius = 800f - ) - - onDrawBehind { - drawRoundRect( - brush = gradient, - cornerRadius = CornerRadius( - cornerRadius.toPx(), - cornerRadius.toPx() - ) - ) - } - } - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f), - shape = RoundedCornerShape(cornerRadius) - ) - .clip(RoundedCornerShape(cornerRadius)) - .clickable(onClick = onExplore) - .padding(start = 24.dp, end = 12.dp, top = 16.dp, bottom = 12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = Icons.Outlined.NewReleases, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp) - ) - Text( - text = stringResource(R.string.help_whats_new_in_version, versionName), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } - IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.help_done), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - } - } - Spacer(Modifier.height(4.dp)) - TextButton(onClick = onExplore) { - Text( - text = stringResource(R.string.help_explore_whats_new), - fontWeight = FontWeight.Medium - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun WhatsNewTimelineCardPreview() { - PreviewHost { - WhatsNewTimelineCard( - versionName = "4.1.2", - onExplore = {}, - onDismiss = {} - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpMockData.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpMockData.kt index 76e6aee998..8e511f51b8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpMockData.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpMockData.kt @@ -13,12 +13,11 @@ import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.Security import com.dot.gallery.core.Position import com.dot.gallery.core.SettingsEntity -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.exif.MetadataDirectory @@ -197,17 +196,6 @@ object HelpMockData { ), ) - val MOCK_LOCATIONS: List = listOf( - LocationMedia(media = MOCK_PHOTOS[0], location = "Bucharest, Romania"), - LocationMedia(media = MOCK_PHOTOS[1], location = "Bucharest, Romania"), - LocationMedia(media = MOCK_PHOTOS[2], location = "Paris, France"), - LocationMedia(media = MOCK_PHOTOS[3], location = "Paris, France"), - LocationMedia(media = MOCK_PHOTOS[4], location = "Paris, France"), - LocationMedia(media = MOCK_PHOTOS[5], location = "New York, United States"), - LocationMedia(media = MOCK_PHOTOS[6], location = "Tokyo, Japan"), - LocationMedia(media = MOCK_PHOTOS[7], location = "Tokyo, Japan"), - ) - val MOCK_METADATA_VIEW_STATE = MetadataViewState( isLoading = false, directories = listOf( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpRepository.kt index cd328ca78f..5b66984c9a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpRepository.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpRepository.kt @@ -7,23 +7,16 @@ package com.dot.gallery.feature_node.presentation.help.data import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoAwesome -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.Contrast import androidx.compose.material.icons.outlined.Draw import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.EditNote -import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.FolderCopy import androidx.compose.material.icons.outlined.ImageSearch import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Panorama -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.ZoomIn import com.dot.gallery.R import com.dot.gallery.feature_node.presentation.util.Screen @@ -34,9 +27,6 @@ object HelpRepository { fun getTipsByCategory(category: HelpCategory): List = ALL_TIPS.filter { it.category == category } fun getTip(id: String): HelpTip? = ALL_TIPS.find { it.id == id } fun getFeaturedTips(): List = listOf("search_ai", "ai_categories").mapNotNull { getTip(it) } - fun getCurrentRelease(): ReleaseNotes = ALL_RELEASES.first() - fun getPreviousReleases(): List = ALL_RELEASES.drop(1) - fun getAllReleases(): List = ALL_RELEASES fun getGetStartedCategories() = listOf( HelpCategory.GET_STARTED_BASICS, HelpCategory.GET_STARTED_NAVIGATION, HelpCategory.GET_STARTED_PERSONALIZATION @@ -44,12 +34,12 @@ object HelpRepository { fun getMakeMostCategories() = listOf( HelpCategory.TIMELINE_ALBUMS, HelpCategory.VIEWING, HelpCategory.VIEWER_ACTIONS, HelpCategory.VIEWER_SETTINGS, HelpCategory.EDITING, HelpCategory.SEARCH, - HelpCategory.AI_FEATURES, HelpCategory.ALBUMS, HelpCategory.VAULT + HelpCategory.AI_FEATURES, HelpCategory.ALBUMS ) fun getExploreMoreCategories() = listOf( - HelpCategory.FAVORITES_TRASH, HelpCategory.LOCATIONS, HelpCategory.METADATA, + HelpCategory.FAVORITES_TRASH, HelpCategory.METADATA, HelpCategory.SETTINGS_APPEARANCE, HelpCategory.SETTINGS_GENERAL, HelpCategory.SETTINGS_NAVIGATION, - HelpCategory.SETTINGS_SMART, HelpCategory.GESTURES, HelpCategory.SELECTION_ACTIONS, + HelpCategory.GESTURES, HelpCategory.SELECTION_ACTIONS, HelpCategory.ACCESSIBILITY ) @@ -63,7 +53,7 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_basics_timeline_p1_title, description = R.string.help_tip_basics_timeline_p1_desc, previewType = PreviewType.TIMELINE_GRID), TutorialPage(title = R.string.help_tip_basics_timeline_p2_title, description = R.string.help_tip_basics_timeline_p2_desc, steps = listOf(R.string.help_tip_basics_timeline_p2_s1, R.string.help_tip_basics_timeline_p2_s2, R.string.help_tip_basics_timeline_p2_s3, R.string.help_tip_basics_timeline_p2_s4, R.string.help_tip_basics_timeline_p2_s5), previewType = PreviewType.TIMELINE_GRID) - ), sinceVersion = "4.0.0" + ) ), HelpTip( id = "basics_view_media", title = R.string.help_tip_basics_view_media_title, @@ -74,7 +64,7 @@ object HelpRepository { TutorialPage(title = R.string.help_tip_basics_view_media_p1_title, description = R.string.help_tip_basics_view_media_p1_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_basics_view_media_p2_title, description = R.string.help_tip_basics_view_media_p2_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_basics_view_media_p3_title, description = R.string.help_tip_basics_view_media_p3_desc, previewType = PreviewType.MEDIA_VIEWER) - ), sinceVersion = "4.0.0" + ) ), HelpTip( id = "basics_select_media", title = R.string.help_tip_basics_select_media_title, @@ -84,7 +74,7 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_basics_select_media_p1_title, description = R.string.help_tip_basics_select_media_p1_desc, steps = listOf(R.string.help_tip_basics_select_media_p1_s1, R.string.help_tip_basics_select_media_p1_s2, R.string.help_tip_basics_select_media_p1_s3, R.string.help_tip_basics_select_media_p1_s4), previewType = PreviewType.TIMELINE_GRID), TutorialPage(title = R.string.help_tip_basics_select_media_p2_title, description = R.string.help_tip_basics_select_media_p2_desc) - ), sinceVersion = "4.0.0" + ) ), HelpTip( id = "basics_share", title = R.string.help_tip_basics_share_title, @@ -93,7 +83,7 @@ object HelpRepository { category = HelpCategory.GET_STARTED_BASICS, pages = listOf( TutorialPage(title = R.string.help_tip_basics_share_p1_title, description = R.string.help_tip_basics_share_p1_desc, steps = listOf(R.string.help_tip_basics_share_p1_s1, R.string.help_tip_basics_share_p1_s2, R.string.help_tip_basics_share_p1_s3, R.string.help_tip_basics_share_p1_s4)) - ), sinceVersion = "4.0.0" + ) ), HelpTip( id = "basics_copy_clipboard", title = R.string.help_tip_basics_copy_clipboard_title, @@ -102,7 +92,7 @@ object HelpRepository { category = HelpCategory.GET_STARTED_BASICS, pages = listOf( TutorialPage(title = R.string.help_tip_basics_copy_clipboard_p1_title, description = R.string.help_tip_basics_copy_clipboard_p1_desc, steps = listOf(R.string.help_tip_basics_copy_clipboard_p1_s1, R.string.help_tip_basics_copy_clipboard_p1_s2, R.string.help_tip_basics_copy_clipboard_p1_s3, R.string.help_tip_basics_copy_clipboard_p1_s4)) - ), sinceVersion = "4.1.1" + ) ) ) // endregion @@ -114,42 +104,42 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_nav_bottom_bar_p1_title, description = R.string.help_tip_nav_bottom_bar_p1_desc, previewType = PreviewType.TIMELINE_GRID), TutorialPage(title = R.string.help_tip_nav_bottom_bar_p2_title, description = R.string.help_tip_nav_bottom_bar_p2_desc) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_albums", title = R.string.help_tip_nav_albums_title, subtitle = R.string.help_tip_nav_albums_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GET_STARTED_NAVIGATION, pages = listOf( TutorialPage(title = R.string.help_tip_nav_albums_p1_title, description = R.string.help_tip_nav_albums_p1_desc, previewType = PreviewType.ALBUM_GRID), TutorialPage(title = R.string.help_tip_nav_albums_p2_title, description = R.string.help_tip_nav_albums_p2_desc, steps = listOf(R.string.help_tip_nav_albums_p2_s1, R.string.help_tip_nav_albums_p2_s2, R.string.help_tip_nav_albums_p2_s3, R.string.help_tip_nav_albums_p2_s4), previewType = PreviewType.ALBUM_GRID) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_search", title = R.string.help_tip_nav_search_title, subtitle = R.string.help_tip_nav_search_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch), category = HelpCategory.GET_STARTED_NAVIGATION, pages = listOf( TutorialPage(title = R.string.help_tip_nav_search_p1_title, description = R.string.help_tip_nav_search_p1_desc, previewType = PreviewType.SEARCH_BAR), TutorialPage(title = R.string.help_tip_nav_search_p2_title, description = R.string.help_tip_nav_search_p2_desc, previewType = PreviewType.AI_SEARCH) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_old_navbar", title = R.string.help_tip_nav_old_navbar_title, subtitle = R.string.help_tip_nav_old_navbar_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GET_STARTED_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_nav_old_navbar_p1_title, description = R.string.help_tip_nav_old_navbar_p1_desc, steps = listOf(R.string.help_tip_nav_old_navbar_p1_s1, R.string.help_tip_nav_old_navbar_p1_s2), previewType = PreviewType.NAV_BAR_PREVIEW) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_library", title = R.string.help_tip_nav_library_title, subtitle = R.string.help_tip_nav_library_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GET_STARTED_NAVIGATION, pages = listOf( TutorialPage(title = R.string.help_tip_nav_library_p1_title, description = R.string.help_tip_nav_library_p1_desc) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_launch_screen", title = R.string.help_tip_nav_launch_screen_title, subtitle = R.string.help_tip_nav_launch_screen_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GET_STARTED_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_nav_launch_screen_p1_title, description = R.string.help_tip_nav_launch_screen_p1_desc, steps = listOf(R.string.help_tip_nav_launch_screen_p1_s1, R.string.help_tip_nav_launch_screen_p1_s2, R.string.help_tip_nav_launch_screen_p1_s3)) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "nav_auto_hide_bars", title = R.string.help_tip_nav_auto_hide_bars_title, subtitle = R.string.help_tip_nav_auto_hide_bars_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GET_STARTED_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_nav_auto_hide_bars_p1_title, description = R.string.help_tip_nav_auto_hide_bars_p1_desc, steps = listOf(R.string.help_tip_nav_auto_hide_bars_p1_s1, R.string.help_tip_nav_auto_hide_bars_p1_s2, R.string.help_tip_nav_auto_hide_bars_p1_s3)) - ), sinceVersion = "4.0.0") + )) ) // endregion @@ -161,40 +151,36 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_personalize_theme_p1_title, description = R.string.help_tip_personalize_theme_p1_desc, previewType = PreviewType.THEME_PICKER), TutorialPage(title = R.string.help_tip_personalize_theme_p2_title, description = R.string.help_tip_personalize_theme_p2_desc, steps = listOf(R.string.help_tip_personalize_theme_p2_s1, R.string.help_tip_personalize_theme_p2_s2, R.string.help_tip_personalize_theme_p2_s3), previewType = PreviewType.THEME_PICKER) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "personalize_colors", title = R.string.help_tip_personalize_colors_title, subtitle = R.string.help_tip_personalize_colors_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, deepLink = Screen.ColorPaletteScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_personalize_colors_p1_title, description = R.string.help_tip_personalize_colors_p1_desc, previewType = PreviewType.COLOR_PALETTE), TutorialPage(title = R.string.help_tip_personalize_colors_p2_title, description = R.string.help_tip_personalize_colors_p2_desc, steps = listOf(R.string.help_tip_personalize_colors_p2_s1, R.string.help_tip_personalize_colors_p2_s2, R.string.help_tip_personalize_colors_p2_s3, R.string.help_tip_personalize_colors_p2_s4), previewType = PreviewType.COLOR_PALETTE) - ), sinceVersion = "4.1.0"), + )), HelpTip(id = "personalize_grid", title = R.string.help_tip_personalize_grid_title, subtitle = R.string.help_tip_personalize_grid_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.ZoomIn), category = HelpCategory.GET_STARTED_PERSONALIZATION, pages = listOf( TutorialPage(title = R.string.help_tip_personalize_grid_p1_title, description = R.string.help_tip_personalize_grid_p1_desc, previewType = PreviewType.PINCH_ZOOM_GRID), TutorialPage(title = R.string.help_tip_personalize_grid_p2_title, description = R.string.help_tip_personalize_grid_p2_desc, previewType = PreviewType.PINCH_ZOOM_GRID) - ), sinceVersion = "4.0.1"), + )), HelpTip(id = "personalize_amoled", title = R.string.help_tip_personalize_amoled_title, subtitle = R.string.help_tip_personalize_amoled_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_personalize_amoled_p1_title, description = R.string.help_tip_personalize_amoled_p1_desc, steps = listOf(R.string.help_tip_personalize_amoled_p1_s1, R.string.help_tip_personalize_amoled_p1_s2, R.string.help_tip_personalize_amoled_p1_s3, R.string.help_tip_personalize_amoled_p1_s4), previewType = PreviewType.THEME_PICKER)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_personalize_amoled_p1_title, description = R.string.help_tip_personalize_amoled_p1_desc, steps = listOf(R.string.help_tip_personalize_amoled_p1_s1, R.string.help_tip_personalize_amoled_p1_s2, R.string.help_tip_personalize_amoled_p1_s3, R.string.help_tip_personalize_amoled_p1_s4), previewType = PreviewType.THEME_PICKER))), HelpTip(id = "personalize_dark_mode", title = R.string.help_tip_personalize_dark_mode_title, subtitle = R.string.help_tip_personalize_dark_mode_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_personalize_dark_mode_p1_title, description = R.string.help_tip_personalize_dark_mode_p1_desc, previewType = PreviewType.THEME_PICKER)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_personalize_dark_mode_p1_title, description = R.string.help_tip_personalize_dark_mode_p1_desc, previewType = PreviewType.THEME_PICKER))), HelpTip(id = "personalize_blur", title = R.string.help_tip_personalize_blur_title, subtitle = R.string.help_tip_personalize_blur_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_personalize_blur_p1_title, description = R.string.help_tip_personalize_blur_p1_desc, steps = listOf(R.string.help_tip_personalize_blur_p1_s1, R.string.help_tip_personalize_blur_p1_s2, R.string.help_tip_personalize_blur_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_personalize_blur_p1_title, description = R.string.help_tip_personalize_blur_p1_desc, steps = listOf(R.string.help_tip_personalize_blur_p1_s1, R.string.help_tip_personalize_blur_p1_s2, R.string.help_tip_personalize_blur_p1_s3)))), HelpTip(id = "personalize_shared_elements", title = R.string.help_tip_personalize_shared_elements_title, subtitle = R.string.help_tip_personalize_shared_elements_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_personalize_shared_elements_p1_title, description = R.string.help_tip_personalize_shared_elements_p1_desc, steps = listOf(R.string.help_tip_personalize_shared_elements_p1_s1, R.string.help_tip_personalize_shared_elements_p1_s2, R.string.help_tip_personalize_shared_elements_p1_s3))), sinceVersion = "4.0.0"), - HelpTip(id = "personalize_app_name", title = R.string.help_tip_personalize_app_name_title, subtitle = R.string.help_tip_personalize_app_name_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.GET_STARTED_PERSONALIZATION, - deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_personalize_app_name_p1_title, description = R.string.help_tip_personalize_app_name_p1_desc, steps = listOf(R.string.help_tip_personalize_app_name_p1_s1, R.string.help_tip_personalize_app_name_p1_s2, R.string.help_tip_personalize_app_name_p1_s3, R.string.help_tip_personalize_app_name_p1_s4))), sinceVersion = "4.1.1") + pages = listOf(TutorialPage(title = R.string.help_tip_personalize_shared_elements_p1_title, description = R.string.help_tip_personalize_shared_elements_p1_desc, steps = listOf(R.string.help_tip_personalize_shared_elements_p1_s1, R.string.help_tip_personalize_shared_elements_p1_s2, R.string.help_tip_personalize_shared_elements_p1_s3)))) ) // endregion @@ -202,41 +188,41 @@ object HelpRepository { private val TIMELINE_ALBUM_TIPS = listOf( HelpTip(id = "timeline_group_month", title = R.string.help_tip_timeline_group_month_title, subtitle = R.string.help_tip_timeline_group_month_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, - pages = listOf(TutorialPage(title = R.string.help_tip_timeline_group_month_p1_title, description = R.string.help_tip_timeline_group_month_p1_desc, previewType = PreviewType.TIMELINE_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_timeline_group_month_p1_title, description = R.string.help_tip_timeline_group_month_p1_desc, previewType = PreviewType.TIMELINE_GRID))), HelpTip(id = "timeline_layout_type", title = R.string.help_tip_timeline_layout_type_title, subtitle = R.string.help_tip_timeline_layout_type_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_timeline_layout_type_p1_title, description = R.string.help_tip_timeline_layout_type_p1_desc, previewType = PreviewType.TIMELINE_GRID), TutorialPage(title = R.string.help_tip_timeline_layout_type_p2_title, description = R.string.help_tip_timeline_layout_type_p2_desc, steps = listOf(R.string.help_tip_timeline_layout_type_p2_s1, R.string.help_tip_timeline_layout_type_p2_s2, R.string.help_tip_timeline_layout_type_p2_s3, R.string.help_tip_timeline_layout_type_p2_s4), previewType = PreviewType.TIMELINE_GRID) - ), sinceVersion = "4.2.0"), + )), HelpTip(id = "timeline_group_similar", title = R.string.help_tip_timeline_group_similar_title, subtitle = R.string.help_tip_timeline_group_similar_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_timeline_group_similar_p1_title, description = R.string.help_tip_timeline_group_similar_p1_desc, previewType = PreviewType.TIMELINE_GRID), TutorialPage(title = R.string.help_tip_timeline_group_similar_p2_title, description = R.string.help_tip_timeline_group_similar_p2_desc, steps = listOf(R.string.help_tip_timeline_group_similar_p2_s1, R.string.help_tip_timeline_group_similar_p2_s2, R.string.help_tip_timeline_group_similar_p2_s3, R.string.help_tip_timeline_group_similar_p2_s4)) - ), sinceVersion = "4.1.2"), + )), HelpTip(id = "timeline_gif_animation", title = R.string.help_tip_timeline_gif_animation_title, subtitle = R.string.help_tip_timeline_gif_animation_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_timeline_gif_animation_p1_title, description = R.string.help_tip_timeline_gif_animation_p1_desc, steps = listOf(R.string.help_tip_timeline_gif_animation_p1_s1, R.string.help_tip_timeline_gif_animation_p1_s2, R.string.help_tip_timeline_gif_animation_p1_s3))), sinceVersion = "4.1.2"), + pages = listOf(TutorialPage(title = R.string.help_tip_timeline_gif_animation_p1_title, description = R.string.help_tip_timeline_gif_animation_p1_desc, steps = listOf(R.string.help_tip_timeline_gif_animation_p1_s1, R.string.help_tip_timeline_gif_animation_p1_s2, R.string.help_tip_timeline_gif_animation_p1_s3)))), HelpTip(id = "albums_hide_timeline", title = R.string.help_tip_albums_hide_timeline_title, subtitle = R.string.help_tip_albums_hide_timeline_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_albums_hide_timeline_p1_title, description = R.string.help_tip_albums_hide_timeline_p1_desc, steps = listOf(R.string.help_tip_albums_hide_timeline_p1_s1, R.string.help_tip_albums_hide_timeline_p1_s2, R.string.help_tip_albums_hide_timeline_p1_s3), previewType = PreviewType.ALBUM_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_albums_hide_timeline_p1_title, description = R.string.help_tip_albums_hide_timeline_p1_desc, steps = listOf(R.string.help_tip_albums_hide_timeline_p1_s1, R.string.help_tip_albums_hide_timeline_p1_s2, R.string.help_tip_albums_hide_timeline_p1_s3), previewType = PreviewType.ALBUM_GRID))), HelpTip(id = "albums_merge_by_name", title = R.string.help_tip_albums_merge_by_name_title, subtitle = R.string.help_tip_albums_merge_by_name_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_albums_merge_by_name_p1_title, description = R.string.help_tip_albums_merge_by_name_p1_desc, steps = listOf(R.string.help_tip_albums_merge_by_name_p1_s1, R.string.help_tip_albums_merge_by_name_p1_s2, R.string.help_tip_albums_merge_by_name_p1_s3), previewType = PreviewType.ALBUM_GRID)), sinceVersion = "4.2.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_albums_merge_by_name_p1_title, description = R.string.help_tip_albums_merge_by_name_p1_desc, steps = listOf(R.string.help_tip_albums_merge_by_name_p1_s1, R.string.help_tip_albums_merge_by_name_p1_s2, R.string.help_tip_albums_merge_by_name_p1_s3), previewType = PreviewType.ALBUM_GRID))), HelpTip(id = "timeline_date_header", title = R.string.help_tip_timeline_date_header_title, subtitle = R.string.help_tip_timeline_date_header_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_timeline_date_header_p1_title, description = R.string.help_tip_timeline_date_header_p1_desc, steps = listOf(R.string.help_tip_timeline_date_header_p1_s1, R.string.help_tip_timeline_date_header_p1_s2, R.string.help_tip_timeline_date_header_p1_s3))), sinceVersion = "4.1.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_timeline_date_header_p1_title, description = R.string.help_tip_timeline_date_header_p1_desc, steps = listOf(R.string.help_tip_timeline_date_header_p1_s1, R.string.help_tip_timeline_date_header_p1_s2, R.string.help_tip_timeline_date_header_p1_s3)))), HelpTip(id = "timeline_fav_icon", title = R.string.help_tip_timeline_fav_icon_title, subtitle = R.string.help_tip_timeline_fav_icon_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.TIMELINE_ALBUMS, deepLink = Screen.SettingsTimelineAlbumsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_timeline_fav_icon_p1_title, description = R.string.help_tip_timeline_fav_icon_p1_desc, steps = listOf(R.string.help_tip_timeline_fav_icon_p1_s1, R.string.help_tip_timeline_fav_icon_p1_s2, R.string.help_tip_timeline_fav_icon_p1_s3))), sinceVersion = "4.1.1") + pages = listOf(TutorialPage(title = R.string.help_tip_timeline_fav_icon_p1_title, description = R.string.help_tip_timeline_fav_icon_p1_desc, steps = listOf(R.string.help_tip_timeline_fav_icon_p1_s1, R.string.help_tip_timeline_fav_icon_p1_s2, R.string.help_tip_timeline_fav_icon_p1_s3)))) ) // endregion @@ -247,45 +233,45 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_view_zoom_p1_title, description = R.string.help_tip_view_zoom_p1_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_view_zoom_p2_title, description = R.string.help_tip_view_zoom_p2_desc, previewType = PreviewType.MEDIA_VIEWER) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "view_video", title = R.string.help_tip_view_video_title, subtitle = R.string.help_tip_view_video_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, deepLink = Screen.SettingsMediaViewerScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_view_video_p1_title, description = R.string.help_tip_view_video_p1_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_view_video_p2_title, description = R.string.help_tip_view_video_p2_desc, steps = listOf(R.string.help_tip_view_video_p2_s1, R.string.help_tip_view_video_p2_s2, R.string.help_tip_view_video_p2_s3, R.string.help_tip_view_video_p2_s4)) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "view_details", title = R.string.help_tip_view_details_title, subtitle = R.string.help_tip_view_details_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, pages = listOf( TutorialPage(title = R.string.help_tip_view_details_p1_title, description = R.string.help_tip_view_details_p1_desc, previewType = PreviewType.EXIF_VIEWER), TutorialPage(title = R.string.help_tip_view_details_p2_title, description = R.string.help_tip_view_details_p2_desc, previewType = PreviewType.EXIF_VIEWER) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "view_swipe", title = R.string.help_tip_view_swipe_title, subtitle = R.string.help_tip_view_swipe_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, - pages = listOf(TutorialPage(title = R.string.help_tip_view_swipe_p1_title, description = R.string.help_tip_view_swipe_p1_desc, previewType = PreviewType.MEDIA_VIEWER)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_view_swipe_p1_title, description = R.string.help_tip_view_swipe_p1_desc, previewType = PreviewType.MEDIA_VIEWER))), HelpTip(id = "view_panorama", title = R.string.help_tip_view_panorama_title, subtitle = R.string.help_tip_view_panorama_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Panorama), category = HelpCategory.VIEWING, - pages = listOf(TutorialPage(title = R.string.help_tip_view_panorama_p1_title, description = R.string.help_tip_view_panorama_p1_desc, previewType = PreviewType.MEDIA_VIEWER)), sinceVersion = "4.1.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_view_panorama_p1_title, description = R.string.help_tip_view_panorama_p1_desc, previewType = PreviewType.MEDIA_VIEWER))), HelpTip(id = "view_motion_photo", title = R.string.help_tip_view_motion_photo_title, subtitle = R.string.help_tip_view_motion_photo_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, - pages = listOf(TutorialPage(title = R.string.help_tip_view_motion_photo_p1_title, description = R.string.help_tip_view_motion_photo_p1_desc, previewType = PreviewType.MEDIA_VIEWER)), sinceVersion = "4.1.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_view_motion_photo_p1_title, description = R.string.help_tip_view_motion_photo_p1_desc, previewType = PreviewType.MEDIA_VIEWER))), HelpTip(id = "view_full_brightness", title = R.string.help_tip_view_full_brightness_title, subtitle = R.string.help_tip_view_full_brightness_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_view_full_brightness_p1_title, description = R.string.help_tip_view_full_brightness_p1_desc, steps = listOf(R.string.help_tip_view_full_brightness_p1_s1, R.string.help_tip_view_full_brightness_p1_s2, R.string.help_tip_view_full_brightness_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_view_full_brightness_p1_title, description = R.string.help_tip_view_full_brightness_p1_desc, steps = listOf(R.string.help_tip_view_full_brightness_p1_s1, R.string.help_tip_view_full_brightness_p1_s2, R.string.help_tip_view_full_brightness_p1_s3)))), HelpTip(id = "view_group_members", title = R.string.help_tip_view_group_members_title, subtitle = R.string.help_tip_view_group_members_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWING, pages = listOf( TutorialPage(title = R.string.help_tip_view_group_members_p1_title, description = R.string.help_tip_view_group_members_p1_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_view_group_members_p2_title, description = R.string.help_tip_view_group_members_p2_desc, previewType = PreviewType.MEDIA_VIEWER) - ), sinceVersion = "4.1.2"), + )), HelpTip(id = "view_lock_image", title = R.string.help_tip_view_lock_image_title, subtitle = R.string.help_tip_view_lock_image_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VIEWING, pages = listOf( TutorialPage(title = R.string.help_tip_view_lock_image_p1_title, description = R.string.help_tip_view_lock_image_p1_desc, previewType = PreviewType.MEDIA_VIEWER), TutorialPage(title = R.string.help_tip_view_lock_image_p2_title, description = R.string.help_tip_view_lock_image_p2_desc, steps = listOf(R.string.help_tip_view_lock_image_p2_s1, R.string.help_tip_view_lock_image_p2_s2, R.string.help_tip_view_lock_image_p2_s3, R.string.help_tip_view_lock_image_p2_s4), previewType = PreviewType.MEDIA_VIEWER) - ), sinceVersion = "4.0.0") + )) ) // endregion @@ -293,31 +279,19 @@ object HelpRepository { private val VIEWER_ACTION_TIPS = listOf( HelpTip(id = "action_share", title = R.string.help_tip_action_share_title, subtitle = R.string.help_tip_action_share_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_action_share_p1_title, description = R.string.help_tip_action_share_p1_desc, steps = listOf(R.string.help_tip_action_share_p1_s1, R.string.help_tip_action_share_p1_s2, R.string.help_tip_action_share_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_action_share_p1_title, description = R.string.help_tip_action_share_p1_desc, steps = listOf(R.string.help_tip_action_share_p1_s1, R.string.help_tip_action_share_p1_s2, R.string.help_tip_action_share_p1_s3)))), HelpTip(id = "action_edit", title = R.string.help_tip_action_edit_title, subtitle = R.string.help_tip_action_edit_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Edit), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_action_edit_p1_title, description = R.string.help_tip_action_edit_p1_desc, steps = listOf(R.string.help_tip_action_edit_p1_s1, R.string.help_tip_action_edit_p1_s2, R.string.help_tip_action_edit_p1_s3, R.string.help_tip_action_edit_p1_s4))), sinceVersion = "4.0.0"), - HelpTip(id = "action_hide_vault", title = R.string.help_tip_action_hide_vault_title, subtitle = R.string.help_tip_action_hide_vault_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf( - TutorialPage(title = R.string.help_tip_action_hide_vault_p1_title, description = R.string.help_tip_action_hide_vault_p1_desc, previewType = PreviewType.VAULT_LOCK), - TutorialPage(title = R.string.help_tip_action_hide_vault_p2_title, description = R.string.help_tip_action_hide_vault_p2_desc, steps = listOf(R.string.help_tip_action_hide_vault_p2_s1, R.string.help_tip_action_hide_vault_p2_s2, R.string.help_tip_action_hide_vault_p2_s3, R.string.help_tip_action_hide_vault_p2_s4), previewType = PreviewType.VAULT_LOCK) - ), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_action_edit_p1_title, description = R.string.help_tip_action_edit_p1_desc, steps = listOf(R.string.help_tip_action_edit_p1_s1, R.string.help_tip_action_edit_p1_s2, R.string.help_tip_action_edit_p1_s3, R.string.help_tip_action_edit_p1_s4)))), HelpTip(id = "action_copy_to_album", title = R.string.help_tip_action_copy_to_album_title, subtitle = R.string.help_tip_action_copy_to_album_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.FolderCopy), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_action_copy_to_album_p1_title, description = R.string.help_tip_action_copy_to_album_p1_desc, steps = listOf(R.string.help_tip_action_copy_to_album_p1_s1, R.string.help_tip_action_copy_to_album_p1_s2, R.string.help_tip_action_copy_to_album_p1_s3, R.string.help_tip_action_copy_to_album_p1_s4))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_action_copy_to_album_p1_title, description = R.string.help_tip_action_copy_to_album_p1_desc, steps = listOf(R.string.help_tip_action_copy_to_album_p1_s1, R.string.help_tip_action_copy_to_album_p1_s2, R.string.help_tip_action_copy_to_album_p1_s3, R.string.help_tip_action_copy_to_album_p1_s4)))), HelpTip(id = "action_add_collection", title = R.string.help_tip_action_add_collection_title, subtitle = R.string.help_tip_action_add_collection_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_action_add_collection_p1_title, description = R.string.help_tip_action_add_collection_p1_desc, previewType = PreviewType.COLLECTION_VIEW)), sinceVersion = "4.2.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_action_add_collection_p1_title, description = R.string.help_tip_action_add_collection_p1_desc, previewType = PreviewType.COLLECTION_VIEW))), HelpTip(id = "action_view_all_metadata", title = R.string.help_tip_action_view_all_metadata_title, subtitle = R.string.help_tip_action_view_all_metadata_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.EditNote), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_action_view_all_metadata_p1_title, description = R.string.help_tip_action_view_all_metadata_p1_desc, previewType = PreviewType.EXIF_VIEWER)), sinceVersion = "4.1.3"), - HelpTip(id = "action_cast", title = R.string.help_tip_viewer_cast_title, subtitle = R.string.help_tip_viewer_cast_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Cast), category = HelpCategory.VIEWER_ACTIONS, - pages = listOf( - TutorialPage(title = R.string.help_tip_viewer_cast_p1_title, description = R.string.help_tip_viewer_cast_p1_desc, previewType = PreviewType.MEDIA_VIEWER), - TutorialPage(title = R.string.help_tip_viewer_cast_p2_title, description = R.string.help_tip_viewer_cast_p2_desc, steps = listOf(R.string.help_tip_viewer_cast_p2_s1, R.string.help_tip_viewer_cast_p2_s2, R.string.help_tip_viewer_cast_p2_s3, R.string.help_tip_viewer_cast_p2_s4), previewType = PreviewType.MEDIA_VIEWER) - ), sinceVersion = "4.2.1") + pages = listOf(TutorialPage(title = R.string.help_tip_action_view_all_metadata_p1_title, description = R.string.help_tip_action_view_all_metadata_p1_desc, previewType = PreviewType.EXIF_VIEWER))) ) private val VIEWER_SETTINGS_TIPS = listOf( @@ -327,31 +301,31 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_viewer_default_editor_p1_title, description = R.string.help_tip_viewer_default_editor_p1_desc, steps = listOf(R.string.help_tip_viewer_default_editor_p1_s1, R.string.help_tip_viewer_default_editor_p1_s2, R.string.help_tip_viewer_default_editor_p1_s3)), TutorialPage(title = R.string.help_tip_viewer_default_editor_p2_title, description = R.string.help_tip_viewer_default_editor_p2_desc) - ), sinceVersion = "4.1.2"), + )), HelpTip(id = "viewer_auto_play", title = R.string.help_tip_viewer_auto_play_title, subtitle = R.string.help_tip_viewer_auto_play_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_play_p1_title, description = R.string.help_tip_viewer_auto_play_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_play_p1_s1, R.string.help_tip_viewer_auto_play_p1_s2))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_play_p1_title, description = R.string.help_tip_viewer_auto_play_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_play_p1_s1, R.string.help_tip_viewer_auto_play_p1_s2)))), HelpTip(id = "viewer_audio_focus", title = R.string.help_tip_viewer_audio_focus_title, subtitle = R.string.help_tip_viewer_audio_focus_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_audio_focus_p1_title, description = R.string.help_tip_viewer_audio_focus_p1_desc, steps = listOf(R.string.help_tip_viewer_audio_focus_p1_s1, R.string.help_tip_viewer_audio_focus_p1_s2, R.string.help_tip_viewer_audio_focus_p1_s3, R.string.help_tip_viewer_audio_focus_p1_s4))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_audio_focus_p1_title, description = R.string.help_tip_viewer_audio_focus_p1_desc, steps = listOf(R.string.help_tip_viewer_audio_focus_p1_s1, R.string.help_tip_viewer_audio_focus_p1_s2, R.string.help_tip_viewer_audio_focus_p1_s3, R.string.help_tip_viewer_audio_focus_p1_s4)))), HelpTip(id = "viewer_auto_hide_video", title = R.string.help_tip_viewer_auto_hide_video_title, subtitle = R.string.help_tip_viewer_auto_hide_video_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_hide_video_p1_title, description = R.string.help_tip_viewer_auto_hide_video_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_hide_video_p1_s1, R.string.help_tip_viewer_auto_hide_video_p1_s2, R.string.help_tip_viewer_auto_hide_video_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_hide_video_p1_title, description = R.string.help_tip_viewer_auto_hide_video_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_hide_video_p1_s1, R.string.help_tip_viewer_auto_hide_video_p1_s2, R.string.help_tip_viewer_auto_hide_video_p1_s3)))), HelpTip(id = "viewer_date_header", title = R.string.help_tip_viewer_date_header_title, subtitle = R.string.help_tip_viewer_date_header_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_date_header_p1_title, description = R.string.help_tip_viewer_date_header_p1_desc, steps = listOf(R.string.help_tip_viewer_date_header_p1_s1, R.string.help_tip_viewer_date_header_p1_s2, R.string.help_tip_viewer_date_header_p1_s3))), sinceVersion = "4.1.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_date_header_p1_title, description = R.string.help_tip_viewer_date_header_p1_desc, steps = listOf(R.string.help_tip_viewer_date_header_p1_s1, R.string.help_tip_viewer_date_header_p1_s2, R.string.help_tip_viewer_date_header_p1_s3)))), HelpTip(id = "viewer_fav_button", title = R.string.help_tip_viewer_fav_button_title, subtitle = R.string.help_tip_viewer_fav_button_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_fav_button_p1_title, description = R.string.help_tip_viewer_fav_button_p1_desc, steps = listOf(R.string.help_tip_viewer_fav_button_p1_s1, R.string.help_tip_viewer_fav_button_p1_s2, R.string.help_tip_viewer_fav_button_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_fav_button_p1_title, description = R.string.help_tip_viewer_fav_button_p1_desc, steps = listOf(R.string.help_tip_viewer_fav_button_p1_s1, R.string.help_tip_viewer_fav_button_p1_s2, R.string.help_tip_viewer_fav_button_p1_s3)))), HelpTip(id = "viewer_auto_contrast", title = R.string.help_tip_viewer_auto_contrast_title, subtitle = R.string.help_tip_viewer_auto_contrast_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Contrast), category = HelpCategory.VIEWER_SETTINGS, deepLink = Screen.SettingsMediaViewerScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_contrast_p1_title, description = R.string.help_tip_viewer_auto_contrast_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_contrast_p1_s1, R.string.help_tip_viewer_auto_contrast_p1_s2, R.string.help_tip_viewer_auto_contrast_p1_s3))), sinceVersion = "4.2.1") + pages = listOf(TutorialPage(title = R.string.help_tip_viewer_auto_contrast_p1_title, description = R.string.help_tip_viewer_auto_contrast_p1_desc, steps = listOf(R.string.help_tip_viewer_auto_contrast_p1_s1, R.string.help_tip_viewer_auto_contrast_p1_s2, R.string.help_tip_viewer_auto_contrast_p1_s3)))) ) // endregion @@ -363,46 +337,38 @@ object HelpRepository { TutorialPage(title = R.string.help_tip_edit_crop_p1_title, description = R.string.help_tip_edit_crop_p1_desc, previewType = PreviewType.PHOTO_EDITOR_CROP), TutorialPage(title = R.string.help_tip_edit_crop_p2_title, description = R.string.help_tip_edit_crop_p2_desc, previewType = PreviewType.PHOTO_EDITOR_CROP), TutorialPage(title = R.string.help_tip_edit_crop_p3_title, description = R.string.help_tip_edit_crop_p3_desc, steps = listOf(R.string.help_tip_edit_crop_p3_s1, R.string.help_tip_edit_crop_p3_s2, R.string.help_tip_edit_crop_p3_s3, R.string.help_tip_edit_crop_p3_s4, R.string.help_tip_edit_crop_p3_s5), previewType = PreviewType.PHOTO_EDITOR_CROP) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "edit_rotate", title = R.string.help_tip_edit_rotate_title, subtitle = R.string.help_tip_edit_rotate_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Edit), category = HelpCategory.EDITING, pages = listOf( TutorialPage(title = R.string.help_tip_edit_rotate_p1_title, description = R.string.help_tip_edit_rotate_p1_desc, previewType = PreviewType.PHOTO_EDITOR_CROP), TutorialPage(title = R.string.help_tip_edit_rotate_p2_title, description = R.string.help_tip_edit_rotate_p2_desc, steps = listOf(R.string.help_tip_edit_rotate_p2_s1, R.string.help_tip_edit_rotate_p2_s2, R.string.help_tip_edit_rotate_p2_s3, R.string.help_tip_edit_rotate_p2_s4)) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "edit_filters", title = R.string.help_tip_edit_filters_title, subtitle = R.string.help_tip_edit_filters_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.EDITING, pages = listOf( TutorialPage(title = R.string.help_tip_edit_filters_p1_title, description = R.string.help_tip_edit_filters_p1_desc, previewType = PreviewType.PHOTO_EDITOR_FILTERS), TutorialPage(title = R.string.help_tip_edit_filters_p2_title, description = R.string.help_tip_edit_filters_p2_desc, steps = listOf(R.string.help_tip_edit_filters_p2_s1, R.string.help_tip_edit_filters_p2_s2, R.string.help_tip_edit_filters_p2_s3, R.string.help_tip_edit_filters_p2_s4, R.string.help_tip_edit_filters_p2_s5), previewType = PreviewType.PHOTO_EDITOR_FILTERS) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "edit_adjustments", title = R.string.help_tip_edit_adjustments_title, subtitle = R.string.help_tip_edit_adjustments_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Edit), category = HelpCategory.EDITING, pages = listOf( TutorialPage(title = R.string.help_tip_edit_adjustments_p1_title, description = R.string.help_tip_edit_adjustments_p1_desc, previewType = PreviewType.PHOTO_EDITOR_FILTERS), TutorialPage(title = R.string.help_tip_edit_adjustments_p2_title, description = R.string.help_tip_edit_adjustments_p2_desc, previewType = PreviewType.PHOTO_EDITOR_FILTERS) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "edit_markup", title = R.string.help_tip_edit_markup_title, subtitle = R.string.help_tip_edit_markup_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Draw), category = HelpCategory.EDITING, pages = listOf( TutorialPage(title = R.string.help_tip_edit_markup_p1_title, description = R.string.help_tip_edit_markup_p1_desc, previewType = PreviewType.PHOTO_EDITOR_MARKUP), TutorialPage(title = R.string.help_tip_edit_markup_p2_title, description = R.string.help_tip_edit_markup_p2_desc, previewType = PreviewType.PHOTO_EDITOR_MARKUP), TutorialPage(title = R.string.help_tip_edit_markup_p3_title, description = R.string.help_tip_edit_markup_p3_desc, steps = listOf(R.string.help_tip_edit_markup_p3_s1, R.string.help_tip_edit_markup_p3_s2, R.string.help_tip_edit_markup_p3_s3, R.string.help_tip_edit_markup_p3_s4, R.string.help_tip_edit_markup_p3_s5, R.string.help_tip_edit_markup_p3_s6), previewType = PreviewType.PHOTO_EDITOR_MARKUP) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "edit_save", title = R.string.help_tip_edit_save_title, subtitle = R.string.help_tip_edit_save_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Edit), category = HelpCategory.EDITING, - deepLink = Screen.EditBackupsViewerScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_edit_save_p1_title, description = R.string.help_tip_edit_save_p1_desc), TutorialPage(title = R.string.help_tip_edit_save_p2_title, description = R.string.help_tip_edit_save_p2_desc, steps = listOf(R.string.help_tip_edit_save_p2_s1, R.string.help_tip_edit_save_p2_s2, R.string.help_tip_edit_save_p2_s3, R.string.help_tip_edit_save_p2_s4)) - ), sinceVersion = "4.0.0"), - HelpTip(id = "edit_backups", title = R.string.help_tip_edit_backups_title, subtitle = R.string.help_tip_edit_backups_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.SettingsBackupRestore), category = HelpCategory.EDITING, - deepLink = Screen.EditBackupsViewerScreen(), - pages = listOf( - TutorialPage(title = R.string.help_tip_edit_backups_p1_title, description = R.string.help_tip_edit_backups_p1_desc), - TutorialPage(title = R.string.help_tip_edit_backups_p2_title, description = R.string.help_tip_edit_backups_p2_desc, steps = listOf(R.string.help_tip_edit_backups_p2_s1, R.string.help_tip_edit_backups_p2_s2, R.string.help_tip_edit_backups_p2_s3, R.string.help_tip_edit_backups_p2_s4)) - ), sinceVersion = "4.0.0") + )) ) // endregion @@ -413,21 +379,20 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_search_text_p1_title, description = R.string.help_tip_search_text_p1_desc, previewType = PreviewType.SEARCH_BAR), TutorialPage(title = R.string.help_tip_search_text_p2_title, description = R.string.help_tip_search_text_p2_desc, previewType = PreviewType.SEARCH_BAR) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "search_ai", title = R.string.help_tip_search_ai_title, subtitle = R.string.help_tip_search_ai_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch), category = HelpCategory.SEARCH, - deepLink = Screen.AIModelsManagerScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_search_ai_p1_title, description = R.string.help_tip_search_ai_p1_desc, previewType = PreviewType.AI_SEARCH), TutorialPage(title = R.string.help_tip_search_ai_p2_title, description = R.string.help_tip_search_ai_p2_desc, previewType = PreviewType.AI_SEARCH), - TutorialPage(title = R.string.help_tip_search_ai_p3_title, description = R.string.help_tip_search_ai_p3_desc, steps = listOf(R.string.help_tip_search_ai_p3_s1, R.string.help_tip_search_ai_p3_s2, R.string.help_tip_search_ai_p3_s3, R.string.help_tip_search_ai_p3_s4), actionLabel = R.string.help_action_open_settings, previewType = PreviewType.AI_SEARCH) - ), sinceVersion = "4.1.0"), + TutorialPage(title = R.string.help_tip_search_ai_p3_title, description = R.string.help_tip_search_ai_p3_desc, steps = listOf(R.string.help_tip_search_ai_p3_s1, R.string.help_tip_search_ai_p3_s2, R.string.help_tip_search_ai_p3_s3, R.string.help_tip_search_ai_p3_s4), previewType = PreviewType.AI_SEARCH) + )), HelpTip(id = "search_history", title = R.string.help_tip_search_history_title, subtitle = R.string.help_tip_search_history_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch), category = HelpCategory.SEARCH, - pages = listOf(TutorialPage(title = R.string.help_tip_search_history_p1_title, description = R.string.help_tip_search_history_p1_desc, previewType = PreviewType.SEARCH_BAR)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_search_history_p1_title, description = R.string.help_tip_search_history_p1_desc, previewType = PreviewType.SEARCH_BAR))), HelpTip(id = "search_indexing", title = R.string.help_tip_search_indexing_title, subtitle = R.string.help_tip_search_indexing_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch), category = HelpCategory.SEARCH, - pages = listOf(TutorialPage(title = R.string.help_tip_search_indexing_p1_title, description = R.string.help_tip_search_indexing_p1_desc)), sinceVersion = "4.1.0") + pages = listOf(TutorialPage(title = R.string.help_tip_search_indexing_p1_title, description = R.string.help_tip_search_indexing_p1_desc))) ) // endregion @@ -439,26 +404,19 @@ object HelpRepository { TutorialPage(title = R.string.help_tip_ai_categories_p1_title, description = R.string.help_tip_ai_categories_p1_desc, previewType = PreviewType.AI_CATEGORIES), TutorialPage(title = R.string.help_tip_ai_categories_p2_title, description = R.string.help_tip_ai_categories_p2_desc, steps = listOf(R.string.help_tip_ai_categories_p2_s1, R.string.help_tip_ai_categories_p2_s2, R.string.help_tip_ai_categories_p2_s3, R.string.help_tip_ai_categories_p2_s4), previewType = PreviewType.AI_CATEGORIES), TutorialPage(title = R.string.help_tip_ai_categories_p3_title, description = R.string.help_tip_ai_categories_p3_desc, previewType = PreviewType.AI_CATEGORIES) - ), sinceVersion = "4.1.0"), + )), HelpTip(id = "ai_create_category", title = R.string.help_tip_ai_create_category_title, subtitle = R.string.help_tip_ai_create_category_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.AutoAwesome), category = HelpCategory.AI_FEATURES, pages = listOf( TutorialPage(title = R.string.help_tip_ai_create_category_p1_title, description = R.string.help_tip_ai_create_category_p1_desc, previewType = PreviewType.AI_CATEGORIES), TutorialPage(title = R.string.help_tip_ai_create_category_p2_title, description = R.string.help_tip_ai_create_category_p2_desc, steps = listOf(R.string.help_tip_ai_create_category_p2_s1, R.string.help_tip_ai_create_category_p2_s2, R.string.help_tip_ai_create_category_p2_s3, R.string.help_tip_ai_create_category_p2_s4, R.string.help_tip_ai_create_category_p2_s5), previewType = PreviewType.AI_CATEGORIES) - ), sinceVersion = "4.1.0"), - HelpTip(id = "ai_models", title = R.string.help_tip_ai_models_title, subtitle = R.string.help_tip_ai_models_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.CloudDownload), category = HelpCategory.AI_FEATURES, - deepLink = Screen.AIModelsManagerScreen(), - pages = listOf( - TutorialPage(title = R.string.help_tip_ai_models_p1_title, description = R.string.help_tip_ai_models_p1_desc), - TutorialPage(title = R.string.help_tip_ai_models_p2_title, description = R.string.help_tip_ai_models_p2_desc, steps = listOf(R.string.help_tip_ai_models_p2_s1, R.string.help_tip_ai_models_p2_s2, R.string.help_tip_ai_models_p2_s3, R.string.help_tip_ai_models_p2_s4), actionLabel = R.string.help_action_open_settings) - ), sinceVersion = "4.1.0"), + )), HelpTip(id = "ai_indexing", title = R.string.help_tip_ai_indexing_title, subtitle = R.string.help_tip_ai_indexing_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.AutoAwesome), category = HelpCategory.AI_FEATURES, pages = listOf( TutorialPage(title = R.string.help_tip_ai_indexing_p1_title, description = R.string.help_tip_ai_indexing_p1_desc), TutorialPage(title = R.string.help_tip_ai_indexing_p2_title, description = R.string.help_tip_ai_indexing_p2_desc) - ), sinceVersion = "4.1.0") + )) ) // endregion @@ -469,64 +427,34 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_albums_browse_p1_title, description = R.string.help_tip_albums_browse_p1_desc, previewType = PreviewType.ALBUM_GRID), TutorialPage(title = R.string.help_tip_albums_browse_p2_title, description = R.string.help_tip_albums_browse_p2_desc, previewType = PreviewType.ALBUM_GRID) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "albums_pin", title = R.string.help_tip_albums_pin_title, subtitle = R.string.help_tip_albums_pin_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ALBUMS, - pages = listOf(TutorialPage(title = R.string.help_tip_albums_pin_p1_title, description = R.string.help_tip_albums_pin_p1_desc, steps = listOf(R.string.help_tip_albums_pin_p1_s1, R.string.help_tip_albums_pin_p1_s2, R.string.help_tip_albums_pin_p1_s3, R.string.help_tip_albums_pin_p1_s4), previewType = PreviewType.ALBUM_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_albums_pin_p1_title, description = R.string.help_tip_albums_pin_p1_desc, steps = listOf(R.string.help_tip_albums_pin_p1_s1, R.string.help_tip_albums_pin_p1_s2, R.string.help_tip_albums_pin_p1_s3, R.string.help_tip_albums_pin_p1_s4), previewType = PreviewType.ALBUM_GRID))), HelpTip(id = "albums_ignore", title = R.string.help_tip_albums_ignore_title, subtitle = R.string.help_tip_albums_ignore_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ALBUMS, pages = listOf( TutorialPage(title = R.string.help_tip_albums_ignore_p1_title, description = R.string.help_tip_albums_ignore_p1_desc, steps = listOf(R.string.help_tip_albums_ignore_p1_s1, R.string.help_tip_albums_ignore_p1_s2, R.string.help_tip_albums_ignore_p1_s3)), TutorialPage(title = R.string.help_tip_albums_ignore_p2_title, description = R.string.help_tip_albums_ignore_p2_desc) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "albums_groups", title = R.string.help_tip_albums_groups_title, subtitle = R.string.help_tip_albums_groups_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ALBUMS, pages = listOf( TutorialPage(title = R.string.help_tip_albums_groups_p1_title, description = R.string.help_tip_albums_groups_p1_desc, previewType = PreviewType.ALBUM_GRID), TutorialPage(title = R.string.help_tip_albums_groups_p2_title, description = R.string.help_tip_albums_groups_p2_desc, steps = listOf(R.string.help_tip_albums_groups_p2_s1, R.string.help_tip_albums_groups_p2_s2, R.string.help_tip_albums_groups_p2_s3, R.string.help_tip_albums_groups_p2_s4, R.string.help_tip_albums_groups_p2_s5), previewType = PreviewType.ALBUM_GRID) - ), sinceVersion = "4.2.0"), + )), HelpTip(id = "albums_collections", title = R.string.help_tip_albums_collections_title, subtitle = R.string.help_tip_albums_collections_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ALBUMS, pages = listOf( TutorialPage(title = R.string.help_tip_albums_collections_p1_title, description = R.string.help_tip_albums_collections_p1_desc, previewType = PreviewType.COLLECTION_VIEW), TutorialPage(title = R.string.help_tip_albums_collections_p2_title, description = R.string.help_tip_albums_collections_p2_desc, steps = listOf(R.string.help_tip_albums_collections_p2_s1, R.string.help_tip_albums_collections_p2_s2, R.string.help_tip_albums_collections_p2_s3, R.string.help_tip_albums_collections_p2_s4), previewType = PreviewType.COLLECTION_VIEW) - ), sinceVersion = "4.2.0"), + )), HelpTip(id = "albums_thumbnails", title = R.string.help_tip_albums_thumbnails_title, subtitle = R.string.help_tip_albums_thumbnails_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ALBUMS, pages = listOf( TutorialPage(title = R.string.help_tip_albums_thumbnails_p1_title, description = R.string.help_tip_albums_thumbnails_p1_desc, previewType = PreviewType.ALBUM_GRID), TutorialPage(title = R.string.help_tip_albums_thumbnails_p2_title, description = R.string.help_tip_albums_thumbnails_p2_desc, steps = listOf(R.string.help_tip_albums_thumbnails_p2_s1, R.string.help_tip_albums_thumbnails_p2_s2, R.string.help_tip_albums_thumbnails_p2_s3, R.string.help_tip_albums_thumbnails_p2_s4), previewType = PreviewType.ALBUM_GRID) - ), sinceVersion = "4.0.0") - ) - // endregion - - // region Vault - private val VAULT_TIPS = listOf( - HelpTip(id = "vault_setup", title = R.string.help_tip_vault_setup_title, subtitle = R.string.help_tip_vault_setup_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VAULT, - pages = listOf( - TutorialPage(title = R.string.help_tip_vault_setup_p1_title, description = R.string.help_tip_vault_setup_p1_desc, previewType = PreviewType.VAULT_LOCK), - TutorialPage(title = R.string.help_tip_vault_setup_p2_title, description = R.string.help_tip_vault_setup_p2_desc, steps = listOf(R.string.help_tip_vault_setup_p2_s1, R.string.help_tip_vault_setup_p2_s2, R.string.help_tip_vault_setup_p2_s3, R.string.help_tip_vault_setup_p2_s4), previewType = PreviewType.VAULT_LOCK), - TutorialPage(title = R.string.help_tip_vault_setup_p3_title, description = R.string.help_tip_vault_setup_p3_desc, previewType = PreviewType.VAULT_LOCK) - ), sinceVersion = "4.0.0"), - HelpTip(id = "vault_add", title = R.string.help_tip_vault_add_title, subtitle = R.string.help_tip_vault_add_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VAULT, - pages = listOf( - TutorialPage(title = R.string.help_tip_vault_add_p1_title, description = R.string.help_tip_vault_add_p1_desc, previewType = PreviewType.VAULT_LOCK), - TutorialPage(title = R.string.help_tip_vault_add_p2_title, description = R.string.help_tip_vault_add_p2_desc, steps = listOf(R.string.help_tip_vault_add_p2_s1, R.string.help_tip_vault_add_p2_s2, R.string.help_tip_vault_add_p2_s3, R.string.help_tip_vault_add_p2_s4), previewType = PreviewType.VAULT_LOCK) - ), sinceVersion = "4.0.0"), - HelpTip(id = "vault_restore", title = R.string.help_tip_vault_restore_title, subtitle = R.string.help_tip_vault_restore_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VAULT, - pages = listOf(TutorialPage(title = R.string.help_tip_vault_restore_p1_title, description = R.string.help_tip_vault_restore_p1_desc, previewType = PreviewType.VAULT_LOCK)), sinceVersion = "4.0.0"), - HelpTip(id = "vault_video_streaming", title = R.string.help_tip_vault_video_streaming_title, subtitle = R.string.help_tip_vault_video_streaming_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VAULT, - pages = listOf(TutorialPage(title = R.string.help_tip_vault_video_streaming_p1_title, description = R.string.help_tip_vault_video_streaming_p1_desc)), sinceVersion = "4.0.0"), - HelpTip(id = "vault_overhaul", title = R.string.help_tip_vault_overhaul_title, subtitle = R.string.help_tip_vault_overhaul_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.VAULT, - pages = listOf( - TutorialPage(title = R.string.help_tip_vault_overhaul_p1_title, description = R.string.help_tip_vault_overhaul_p1_desc, steps = listOf(R.string.help_tip_vault_overhaul_p1_s1, R.string.help_tip_vault_overhaul_p1_s2, R.string.help_tip_vault_overhaul_p1_s3), previewType = PreviewType.VAULT_LOCK), - TutorialPage(title = R.string.help_tip_vault_overhaul_p2_title, description = R.string.help_tip_vault_overhaul_p2_desc, steps = listOf(R.string.help_tip_vault_overhaul_p2_s1, R.string.help_tip_vault_overhaul_p2_s2, R.string.help_tip_vault_overhaul_p2_s3), previewType = PreviewType.VAULT_LOCK) - ), sinceVersion = "4.2.1") + )) ) // endregion @@ -534,34 +462,20 @@ object HelpRepository { private val FAV_TRASH_TIPS = listOf( HelpTip(id = "fav_add", title = R.string.help_tip_fav_add_title, subtitle = R.string.help_tip_fav_add_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.FAVORITES_TRASH, - pages = listOf(TutorialPage(title = R.string.help_tip_fav_add_p1_title, description = R.string.help_tip_fav_add_p1_desc, previewType = PreviewType.FAVORITES_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_fav_add_p1_title, description = R.string.help_tip_fav_add_p1_desc, previewType = PreviewType.FAVORITES_GRID))), HelpTip(id = "fav_browse", title = R.string.help_tip_fav_browse_title, subtitle = R.string.help_tip_fav_browse_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.FAVORITES_TRASH, - pages = listOf(TutorialPage(title = R.string.help_tip_fav_browse_p1_title, description = R.string.help_tip_fav_browse_p1_desc, previewType = PreviewType.FAVORITES_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_fav_browse_p1_title, description = R.string.help_tip_fav_browse_p1_desc, previewType = PreviewType.FAVORITES_GRID))), HelpTip(id = "trash_delete", title = R.string.help_tip_trash_delete_title, subtitle = R.string.help_tip_trash_delete_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.FAVORITES_TRASH, pages = listOf( TutorialPage(title = R.string.help_tip_trash_delete_p1_title, description = R.string.help_tip_trash_delete_p1_desc, previewType = PreviewType.TRASH_GRID), TutorialPage(title = R.string.help_tip_trash_delete_p2_title, description = R.string.help_tip_trash_delete_p2_desc, steps = listOf(R.string.help_tip_trash_delete_p2_s1, R.string.help_tip_trash_delete_p2_s2, R.string.help_tip_trash_delete_p2_s3, R.string.help_tip_trash_delete_p2_s4), previewType = PreviewType.TRASH_GRID) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "trash_enable", title = R.string.help_tip_trash_enable_title, subtitle = R.string.help_tip_trash_enable_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.FAVORITES_TRASH, deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_trash_enable_p1_title, description = R.string.help_tip_trash_enable_p1_desc, steps = listOf(R.string.help_tip_trash_enable_p1_s1, R.string.help_tip_trash_enable_p1_s2, R.string.help_tip_trash_enable_p1_s3, R.string.help_tip_trash_enable_p1_s4))), sinceVersion = "4.0.0") - ) - // endregion - - // region Locations - private val LOCATION_TIPS = listOf( - HelpTip(id = "location_browse", title = R.string.help_tip_location_browse_title, subtitle = R.string.help_tip_location_browse_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.LocationOn), category = HelpCategory.LOCATIONS, - pages = listOf( - TutorialPage(title = R.string.help_tip_location_browse_p1_title, description = R.string.help_tip_location_browse_p1_desc, previewType = PreviewType.LOCATION_MAP), - TutorialPage(title = R.string.help_tip_location_browse_p2_title, description = R.string.help_tip_location_browse_p2_desc, previewType = PreviewType.LOCATION_MAP) - ), sinceVersion = "4.0.0"), - HelpTip(id = "location_viewer", title = R.string.help_tip_location_viewer_title, subtitle = R.string.help_tip_location_viewer_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.LocationOn), category = HelpCategory.LOCATIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_location_viewer_p1_title, description = R.string.help_tip_location_viewer_p1_desc)), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_trash_enable_p1_title, description = R.string.help_tip_trash_enable_p1_desc, steps = listOf(R.string.help_tip_trash_enable_p1_s1, R.string.help_tip_trash_enable_p1_s2, R.string.help_tip_trash_enable_p1_s3, R.string.help_tip_trash_enable_p1_s4)))) ) // endregion @@ -572,20 +486,20 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_exif_view_p1_title, description = R.string.help_tip_exif_view_p1_desc, previewType = PreviewType.EXIF_VIEWER), TutorialPage(title = R.string.help_tip_exif_view_p2_title, description = R.string.help_tip_exif_view_p2_desc, previewType = PreviewType.EXIF_VIEWER) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "exif_edit", title = R.string.help_tip_exif_edit_title, subtitle = R.string.help_tip_exif_edit_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.EditNote), category = HelpCategory.METADATA, pages = listOf( TutorialPage(title = R.string.help_tip_exif_edit_p1_title, description = R.string.help_tip_exif_edit_p1_desc, previewType = PreviewType.EXIF_VIEWER), TutorialPage(title = R.string.help_tip_exif_edit_p2_title, description = R.string.help_tip_exif_edit_p2_desc, steps = listOf(R.string.help_tip_exif_edit_p2_s1, R.string.help_tip_exif_edit_p2_s2, R.string.help_tip_exif_edit_p2_s3, R.string.help_tip_exif_edit_p2_s4), previewType = PreviewType.EXIF_VIEWER) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "exif_delete_location", title = R.string.help_tip_exif_delete_location_title, subtitle = R.string.help_tip_exif_delete_location_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.EditNote), category = HelpCategory.METADATA, - pages = listOf(TutorialPage(title = R.string.help_tip_exif_delete_location_p1_title, description = R.string.help_tip_exif_delete_location_p1_desc, steps = listOf(R.string.help_tip_exif_delete_location_p1_s1, R.string.help_tip_exif_delete_location_p1_s2, R.string.help_tip_exif_delete_location_p1_s3, R.string.help_tip_exif_delete_location_p1_s4))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_exif_delete_location_p1_title, description = R.string.help_tip_exif_delete_location_p1_desc, steps = listOf(R.string.help_tip_exif_delete_location_p1_s1, R.string.help_tip_exif_delete_location_p1_s2, R.string.help_tip_exif_delete_location_p1_s3, R.string.help_tip_exif_delete_location_p1_s4)))), HelpTip(id = "exif_refresh", title = R.string.help_tip_exif_refresh_title, subtitle = R.string.help_tip_exif_refresh_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.EditNote), category = HelpCategory.METADATA, deepLink = Screen.SettingsSmartFeaturesScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_exif_refresh_p1_title, description = R.string.help_tip_exif_refresh_p1_desc, steps = listOf(R.string.help_tip_exif_refresh_p1_s1, R.string.help_tip_exif_refresh_p1_s2, R.string.help_tip_exif_refresh_p1_s3, R.string.help_tip_exif_refresh_p1_s4))), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_exif_refresh_p1_title, description = R.string.help_tip_exif_refresh_p1_desc, steps = listOf(R.string.help_tip_exif_refresh_p1_s1, R.string.help_tip_exif_refresh_p1_s2, R.string.help_tip_exif_refresh_p1_s3, R.string.help_tip_exif_refresh_p1_s4)))) ) // endregion @@ -597,74 +511,58 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_settings_color_palette_p1_title, description = R.string.help_tip_settings_color_palette_p1_desc, previewType = PreviewType.COLOR_PALETTE), TutorialPage(title = R.string.help_tip_settings_color_palette_p2_title, description = R.string.help_tip_settings_color_palette_p2_desc, steps = listOf(R.string.help_tip_settings_color_palette_p2_s1, R.string.help_tip_settings_color_palette_p2_s2, R.string.help_tip_settings_color_palette_p2_s3), previewType = PreviewType.COLOR_PALETTE) - ), sinceVersion = "4.1.0"), + )), HelpTip(id = "settings_dark_mode", title = R.string.help_tip_settings_dark_mode_title, subtitle = R.string.help_tip_settings_dark_mode_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.SETTINGS_APPEARANCE, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_dark_mode_p1_title, description = R.string.help_tip_settings_dark_mode_p1_desc, steps = listOf(R.string.help_tip_settings_dark_mode_p1_s1, R.string.help_tip_settings_dark_mode_p1_s2, R.string.help_tip_settings_dark_mode_p1_s3), previewType = PreviewType.THEME_PICKER)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_dark_mode_p1_title, description = R.string.help_tip_settings_dark_mode_p1_desc, steps = listOf(R.string.help_tip_settings_dark_mode_p1_s1, R.string.help_tip_settings_dark_mode_p1_s2, R.string.help_tip_settings_dark_mode_p1_s3), previewType = PreviewType.THEME_PICKER))), HelpTip(id = "settings_amoled", title = R.string.help_tip_settings_amoled_title, subtitle = R.string.help_tip_settings_amoled_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Palette), category = HelpCategory.SETTINGS_APPEARANCE, deepLink = Screen.SettingsAppearanceScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_amoled_p1_title, description = R.string.help_tip_settings_amoled_p1_desc, steps = listOf(R.string.help_tip_settings_amoled_p1_s1, R.string.help_tip_settings_amoled_p1_s2, R.string.help_tip_settings_amoled_p1_s3, R.string.help_tip_settings_amoled_p1_s4), previewType = PreviewType.THEME_PICKER)), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_settings_amoled_p1_title, description = R.string.help_tip_settings_amoled_p1_desc, steps = listOf(R.string.help_tip_settings_amoled_p1_s1, R.string.help_tip_settings_amoled_p1_s2, R.string.help_tip_settings_amoled_p1_s3, R.string.help_tip_settings_amoled_p1_s4), previewType = PreviewType.THEME_PICKER))) ) private val SETTINGS_GENERAL_TIPS = listOf( HelpTip(id = "settings_trash_toggle", title = R.string.help_tip_settings_trash_toggle_title, subtitle = R.string.help_tip_settings_trash_toggle_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_GENERAL, deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_trash_toggle_p1_title, description = R.string.help_tip_settings_trash_toggle_p1_desc, steps = listOf(R.string.help_tip_settings_trash_toggle_p1_s1, R.string.help_tip_settings_trash_toggle_p1_s2, R.string.help_tip_settings_trash_toggle_p1_s3), previewType = PreviewType.SETTINGS_GENERAL)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_trash_toggle_p1_title, description = R.string.help_tip_settings_trash_toggle_p1_desc, steps = listOf(R.string.help_tip_settings_trash_toggle_p1_s1, R.string.help_tip_settings_trash_toggle_p1_s2, R.string.help_tip_settings_trash_toggle_p1_s3), previewType = PreviewType.SETTINGS_GENERAL))), HelpTip(id = "settings_trash_confirm", title = R.string.help_tip_settings_trash_confirm_title, subtitle = R.string.help_tip_settings_trash_confirm_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_GENERAL, deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_trash_confirm_p1_title, description = R.string.help_tip_settings_trash_confirm_p1_desc, steps = listOf(R.string.help_tip_settings_trash_confirm_p1_s1, R.string.help_tip_settings_trash_confirm_p1_s2, R.string.help_tip_settings_trash_confirm_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_trash_confirm_p1_title, description = R.string.help_tip_settings_trash_confirm_p1_desc, steps = listOf(R.string.help_tip_settings_trash_confirm_p1_s1, R.string.help_tip_settings_trash_confirm_p1_s2, R.string.help_tip_settings_trash_confirm_p1_s3)))), HelpTip(id = "settings_secure_mode", title = R.string.help_tip_settings_secure_mode_title, subtitle = R.string.help_tip_settings_secure_mode_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Lock), category = HelpCategory.SETTINGS_GENERAL, deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_secure_mode_p1_title, description = R.string.help_tip_settings_secure_mode_p1_desc, steps = listOf(R.string.help_tip_settings_secure_mode_p1_s1, R.string.help_tip_settings_secure_mode_p1_s2, R.string.help_tip_settings_secure_mode_p1_s3, R.string.help_tip_settings_secure_mode_p1_s4), previewType = PreviewType.SETTINGS_GENERAL)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_secure_mode_p1_title, description = R.string.help_tip_settings_secure_mode_p1_desc, steps = listOf(R.string.help_tip_settings_secure_mode_p1_s1, R.string.help_tip_settings_secure_mode_p1_s2, R.string.help_tip_settings_secure_mode_p1_s3, R.string.help_tip_settings_secure_mode_p1_s4), previewType = PreviewType.SETTINGS_GENERAL))), HelpTip(id = "settings_vibrations", title = R.string.help_tip_settings_vibrations_title, subtitle = R.string.help_tip_settings_vibrations_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_GENERAL, deepLink = Screen.SettingsGeneralScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_vibrations_p1_title, description = R.string.help_tip_settings_vibrations_p1_desc, steps = listOf(R.string.help_tip_settings_vibrations_p1_s1, R.string.help_tip_settings_vibrations_p1_s2, R.string.help_tip_settings_vibrations_p1_s3))), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_settings_vibrations_p1_title, description = R.string.help_tip_settings_vibrations_p1_desc, steps = listOf(R.string.help_tip_settings_vibrations_p1_s1, R.string.help_tip_settings_vibrations_p1_s2, R.string.help_tip_settings_vibrations_p1_s3)))) ) private val SETTINGS_NAV_TIPS = listOf( HelpTip(id = "settings_launch_screen", title = R.string.help_tip_settings_launch_screen_title, subtitle = R.string.help_tip_settings_launch_screen_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_launch_screen_p1_title, description = R.string.help_tip_settings_launch_screen_p1_desc, steps = listOf(R.string.help_tip_settings_launch_screen_p1_s1, R.string.help_tip_settings_launch_screen_p1_s2, R.string.help_tip_settings_launch_screen_p1_s3))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_launch_screen_p1_title, description = R.string.help_tip_settings_launch_screen_p1_desc, steps = listOf(R.string.help_tip_settings_launch_screen_p1_s1, R.string.help_tip_settings_launch_screen_p1_s2, R.string.help_tip_settings_launch_screen_p1_s3)))), HelpTip(id = "settings_old_navbar", title = R.string.help_tip_settings_old_navbar_title, subtitle = R.string.help_tip_settings_old_navbar_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_old_navbar_p1_title, description = R.string.help_tip_settings_old_navbar_p1_desc, steps = listOf(R.string.help_tip_settings_old_navbar_p1_s1, R.string.help_tip_settings_old_navbar_p1_s2, R.string.help_tip_settings_old_navbar_p1_s3), previewType = PreviewType.NAV_BAR_PREVIEW)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_old_navbar_p1_title, description = R.string.help_tip_settings_old_navbar_p1_desc, steps = listOf(R.string.help_tip_settings_old_navbar_p1_s1, R.string.help_tip_settings_old_navbar_p1_s2, R.string.help_tip_settings_old_navbar_p1_s3), previewType = PreviewType.NAV_BAR_PREVIEW))), HelpTip(id = "settings_selection_titles", title = R.string.help_tip_settings_selection_titles_title, subtitle = R.string.help_tip_settings_selection_titles_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_NAVIGATION, deepLink = Screen.SettingsNavigationScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_settings_selection_titles_p1_title, description = R.string.help_tip_settings_selection_titles_p1_desc, steps = listOf(R.string.help_tip_settings_selection_titles_p1_s1, R.string.help_tip_settings_selection_titles_p1_s2, R.string.help_tip_settings_selection_titles_p1_s3, R.string.help_tip_settings_selection_titles_p1_s4))), sinceVersion = "4.1.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_settings_selection_titles_p1_title, description = R.string.help_tip_settings_selection_titles_p1_desc, steps = listOf(R.string.help_tip_settings_selection_titles_p1_s1, R.string.help_tip_settings_selection_titles_p1_s2, R.string.help_tip_settings_selection_titles_p1_s3, R.string.help_tip_settings_selection_titles_p1_s4)))), HelpTip(id = "settings_selection_actions", title = R.string.help_tip_settings_selection_actions_title, subtitle = R.string.help_tip_settings_selection_actions_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SETTINGS_NAVIGATION, deepLink = Screen.SettingsSelectionActionsScreen(), pages = listOf( TutorialPage(title = R.string.help_tip_settings_selection_actions_p1_title, description = R.string.help_tip_settings_selection_actions_p1_desc), TutorialPage(title = R.string.help_tip_settings_selection_actions_p2_title, description = R.string.help_tip_settings_selection_actions_p2_desc, steps = listOf(R.string.help_tip_settings_selection_actions_p2_s1, R.string.help_tip_settings_selection_actions_p2_s2, R.string.help_tip_settings_selection_actions_p2_s3, R.string.help_tip_settings_selection_actions_p2_s4)) - ), sinceVersion = "4.0.0") + )) ) - private val SETTINGS_SMART_TIPS = listOf( - HelpTip(id = "settings_ai_models", title = R.string.help_tip_settings_ai_models_title, subtitle = R.string.help_tip_settings_ai_models_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.AutoAwesome), category = HelpCategory.SETTINGS_SMART, - deepLink = Screen.AIModelsManagerScreen(), - pages = listOf( - TutorialPage(title = R.string.help_tip_settings_ai_models_p1_title, description = R.string.help_tip_settings_ai_models_p1_desc), - TutorialPage(title = R.string.help_tip_settings_ai_models_p2_title, description = R.string.help_tip_settings_ai_models_p2_desc, steps = listOf(R.string.help_tip_settings_ai_models_p2_s1, R.string.help_tip_settings_ai_models_p2_s2, R.string.help_tip_settings_ai_models_p2_s3), actionLabel = R.string.help_action_open_settings) - ), sinceVersion = "4.1.0"), - HelpTip(id = "settings_edit_backups", title = R.string.help_tip_settings_edit_backups_title, subtitle = R.string.help_tip_settings_edit_backups_subtitle, - icon = HelpIcon.ofVector(Icons.Outlined.SettingsBackupRestore), category = HelpCategory.SETTINGS_SMART, - deepLink = Screen.EditBackupsViewerScreen(), - pages = listOf( - TutorialPage(title = R.string.help_tip_settings_edit_backups_p1_title, description = R.string.help_tip_settings_edit_backups_p1_desc), - TutorialPage(title = R.string.help_tip_settings_edit_backups_p2_title, description = R.string.help_tip_settings_edit_backups_p2_desc, steps = listOf(R.string.help_tip_settings_edit_backups_p2_s1, R.string.help_tip_settings_edit_backups_p2_s2, R.string.help_tip_settings_edit_backups_p2_s3)) - ), sinceVersion = "4.0.0") - ) // endregion // region Gestures & Selection @@ -674,16 +572,16 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_gesture_pinch_grid_p1_title, description = R.string.help_tip_gesture_pinch_grid_p1_desc, previewType = PreviewType.PINCH_ZOOM_GRID), TutorialPage(title = R.string.help_tip_gesture_pinch_grid_p2_title, description = R.string.help_tip_gesture_pinch_grid_p2_desc, previewType = PreviewType.PINCH_ZOOM_GRID) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "gesture_swipe_viewer", title = R.string.help_tip_gesture_swipe_viewer_title, subtitle = R.string.help_tip_gesture_swipe_viewer_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GESTURES, - pages = listOf(TutorialPage(title = R.string.help_tip_gesture_swipe_viewer_p1_title, description = R.string.help_tip_gesture_swipe_viewer_p1_desc, previewType = PreviewType.MEDIA_VIEWER)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_gesture_swipe_viewer_p1_title, description = R.string.help_tip_gesture_swipe_viewer_p1_desc, previewType = PreviewType.MEDIA_VIEWER))), HelpTip(id = "gesture_long_press", title = R.string.help_tip_gesture_long_press_title, subtitle = R.string.help_tip_gesture_long_press_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GESTURES, - pages = listOf(TutorialPage(title = R.string.help_tip_gesture_long_press_p1_title, description = R.string.help_tip_gesture_long_press_p1_desc, previewType = PreviewType.TIMELINE_GRID)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_gesture_long_press_p1_title, description = R.string.help_tip_gesture_long_press_p1_desc, previewType = PreviewType.TIMELINE_GRID))), HelpTip(id = "gesture_multi_select", title = R.string.help_tip_gesture_multi_select_title, subtitle = R.string.help_tip_gesture_multi_select_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.GESTURES, - pages = listOf(TutorialPage(title = R.string.help_tip_gesture_multi_select_p1_title, description = R.string.help_tip_gesture_multi_select_p1_desc, previewType = PreviewType.TIMELINE_GRID)), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_gesture_multi_select_p1_title, description = R.string.help_tip_gesture_multi_select_p1_desc, previewType = PreviewType.TIMELINE_GRID))) ) private val SELECTION_TIPS = listOf( @@ -692,17 +590,17 @@ object HelpRepository { pages = listOf( TutorialPage(title = R.string.help_tip_selection_sheet_p1_title, description = R.string.help_tip_selection_sheet_p1_desc), TutorialPage(title = R.string.help_tip_selection_sheet_p2_title, description = R.string.help_tip_selection_sheet_p2_desc, steps = listOf(R.string.help_tip_selection_sheet_p2_s1, R.string.help_tip_selection_sheet_p2_s2, R.string.help_tip_selection_sheet_p2_s3, R.string.help_tip_selection_sheet_p2_s4)) - ), sinceVersion = "4.0.0"), + )), HelpTip(id = "selection_batch_share", title = R.string.help_tip_selection_batch_share_title, subtitle = R.string.help_tip_selection_batch_share_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SELECTION_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_selection_batch_share_p1_title, description = R.string.help_tip_selection_batch_share_p1_desc, steps = listOf(R.string.help_tip_selection_batch_share_p1_s1, R.string.help_tip_selection_batch_share_p1_s2, R.string.help_tip_selection_batch_share_p1_s3, R.string.help_tip_selection_batch_share_p1_s4))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_selection_batch_share_p1_title, description = R.string.help_tip_selection_batch_share_p1_desc, steps = listOf(R.string.help_tip_selection_batch_share_p1_s1, R.string.help_tip_selection_batch_share_p1_s2, R.string.help_tip_selection_batch_share_p1_s3, R.string.help_tip_selection_batch_share_p1_s4)))), HelpTip(id = "selection_batch_delete", title = R.string.help_tip_selection_batch_delete_title, subtitle = R.string.help_tip_selection_batch_delete_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SELECTION_ACTIONS, - pages = listOf(TutorialPage(title = R.string.help_tip_selection_batch_delete_p1_title, description = R.string.help_tip_selection_batch_delete_p1_desc, steps = listOf(R.string.help_tip_selection_batch_delete_p1_s1, R.string.help_tip_selection_batch_delete_p1_s2, R.string.help_tip_selection_batch_delete_p1_s3, R.string.help_tip_selection_batch_delete_p1_s4))), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_selection_batch_delete_p1_title, description = R.string.help_tip_selection_batch_delete_p1_desc, steps = listOf(R.string.help_tip_selection_batch_delete_p1_s1, R.string.help_tip_selection_batch_delete_p1_s2, R.string.help_tip_selection_batch_delete_p1_s3, R.string.help_tip_selection_batch_delete_p1_s4)))), HelpTip(id = "selection_right_align", title = R.string.help_tip_selection_right_align_title, subtitle = R.string.help_tip_selection_right_align_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.SELECTION_ACTIONS, deepLink = Screen.SettingsSelectionActionsScreen(), - pages = listOf(TutorialPage(title = R.string.help_tip_selection_right_align_p1_title, description = R.string.help_tip_selection_right_align_p1_desc, steps = listOf(R.string.help_tip_selection_right_align_p1_s1, R.string.help_tip_selection_right_align_p1_s2, R.string.help_tip_selection_right_align_p1_s3))), sinceVersion = "4.2.1") + pages = listOf(TutorialPage(title = R.string.help_tip_selection_right_align_p1_title, description = R.string.help_tip_selection_right_align_p1_desc, steps = listOf(R.string.help_tip_selection_right_align_p1_s1, R.string.help_tip_selection_right_align_p1_s2, R.string.help_tip_selection_right_align_p1_s3)))) ) // endregion @@ -710,120 +608,26 @@ object HelpRepository { private val ACCESSIBILITY_TIPS = listOf( HelpTip(id = "accessibility_font", title = R.string.help_tip_accessibility_font_title, subtitle = R.string.help_tip_accessibility_font_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ACCESSIBILITY, - pages = listOf(TutorialPage(title = R.string.help_tip_accessibility_font_p1_title, description = R.string.help_tip_accessibility_font_p1_desc)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_accessibility_font_p1_title, description = R.string.help_tip_accessibility_font_p1_desc))), HelpTip(id = "advanced_media_picker", title = R.string.help_tip_advanced_media_picker_title, subtitle = R.string.help_tip_advanced_media_picker_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ACCESSIBILITY, - pages = listOf(TutorialPage(title = R.string.help_tip_advanced_media_picker_p1_title, description = R.string.help_tip_advanced_media_picker_p1_desc)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_advanced_media_picker_p1_title, description = R.string.help_tip_advanced_media_picker_p1_desc))), HelpTip(id = "advanced_standalone", title = R.string.help_tip_advanced_standalone_title, subtitle = R.string.help_tip_advanced_standalone_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ACCESSIBILITY, - pages = listOf(TutorialPage(title = R.string.help_tip_advanced_standalone_p1_title, description = R.string.help_tip_advanced_standalone_p1_desc)), sinceVersion = "4.0.0"), + pages = listOf(TutorialPage(title = R.string.help_tip_advanced_standalone_p1_title, description = R.string.help_tip_advanced_standalone_p1_desc))), HelpTip(id = "advanced_wallpaper", title = R.string.help_tip_advanced_wallpaper_title, subtitle = R.string.help_tip_advanced_wallpaper_subtitle, icon = HelpIcon.ofVector(Icons.Outlined.Collections), category = HelpCategory.ACCESSIBILITY, - pages = listOf(TutorialPage(title = R.string.help_tip_advanced_wallpaper_p1_title, description = R.string.help_tip_advanced_wallpaper_p1_desc, steps = listOf(R.string.help_tip_advanced_wallpaper_p1_s1, R.string.help_tip_advanced_wallpaper_p1_s2, R.string.help_tip_advanced_wallpaper_p1_s3, R.string.help_tip_advanced_wallpaper_p1_s4, R.string.help_tip_advanced_wallpaper_p1_s5))), sinceVersion = "4.0.0") + pages = listOf(TutorialPage(title = R.string.help_tip_advanced_wallpaper_p1_title, description = R.string.help_tip_advanced_wallpaper_p1_desc, steps = listOf(R.string.help_tip_advanced_wallpaper_p1_s1, R.string.help_tip_advanced_wallpaper_p1_s2, R.string.help_tip_advanced_wallpaper_p1_s3, R.string.help_tip_advanced_wallpaper_p1_s4, R.string.help_tip_advanced_wallpaper_p1_s5)))) ) // endregion // region Aggregation private val ALL_TIPS: List = BASICS_TIPS + NAVIGATION_TIPS + PERSONALIZATION_TIPS + TIMELINE_ALBUM_TIPS + VIEWING_TIPS + VIEWER_ACTION_TIPS + VIEWER_SETTINGS_TIPS + - EDITING_TIPS + SEARCH_TIPS + AI_TIPS + ALBUM_TIPS + VAULT_TIPS + - FAV_TRASH_TIPS + LOCATION_TIPS + METADATA_TIPS + - SETTINGS_APPEARANCE_TIPS + SETTINGS_GENERAL_TIPS + SETTINGS_NAV_TIPS + SETTINGS_SMART_TIPS + + EDITING_TIPS + SEARCH_TIPS + AI_TIPS + ALBUM_TIPS + + FAV_TRASH_TIPS + METADATA_TIPS + + SETTINGS_APPEARANCE_TIPS + SETTINGS_GENERAL_TIPS + SETTINGS_NAV_TIPS + GESTURE_TIPS + SELECTION_TIPS + ACCESSIBILITY_TIPS - private val ALL_RELEASES: List = listOf( - ReleaseNotes( - versionName = "4.2.1", - versionCode = 42101, - releaseDate = "2026-05-12", - highlights = listOf( - ReleaseHighlight(tipId = "action_cast", title = R.string.help_release_421_fcast_casting_title, description = R.string.help_release_421_fcast_casting_desc, icon = HelpIcon.ofVector(Icons.Outlined.Cast)), - ReleaseHighlight(tipId = "viewer_auto_contrast", title = R.string.help_release_421_auto_contrast_title, description = R.string.help_release_421_auto_contrast_desc, icon = HelpIcon.ofVector(Icons.Outlined.Contrast)), - ReleaseHighlight(tipId = "vault_overhaul", title = R.string.help_release_421_vault_overhaul_title, description = R.string.help_release_421_vault_overhaul_desc, icon = HelpIcon.ofVector(Icons.Outlined.Lock)), - ReleaseHighlight(tipId = "selection_right_align", title = R.string.help_release_421_right_align_actions_title, description = R.string.help_release_421_right_align_actions_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = null, title = R.string.help_release_421_nomaps_withml_title, description = R.string.help_release_421_nomaps_withml_desc, icon = HelpIcon.ofVector(Icons.Outlined.CloudDownload)), - ReleaseHighlight(tipId = null, title = R.string.help_release_421_bugfixes_title, description = R.string.help_release_421_bugfixes_desc, icon = HelpIcon.ofVector(Icons.Outlined.BugReport)) - ) - ), - ReleaseNotes( - versionName = "4.2.0", - versionCode = 42001, - releaseDate = "2026-05-01", - highlights = listOf( - ReleaseHighlight(tipId = "albums_groups", title = R.string.help_release_420_album_groups_title, description = R.string.help_release_420_album_groups_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "albums_merge_by_name", title = R.string.help_release_420_merge_albums_title, description = R.string.help_release_420_merge_albums_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = null, title = R.string.help_release_420_merge_subfolders_title, description = R.string.help_release_420_merge_subfolders_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = null, title = R.string.help_release_420_video_seeking_title, description = R.string.help_release_420_video_seeking_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "search_ai", title = R.string.help_release_420_image_search_title, description = R.string.help_release_420_image_search_desc, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch)), - ReleaseHighlight(tipId = "timeline_layout_type", title = R.string.help_release_420_mosaic_layout_title, description = R.string.help_release_420_mosaic_layout_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "albums_collections", title = R.string.help_release_420_collections_title, description = R.string.help_release_420_collections_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = null, title = R.string.help_release_420_settings_revamp_title, description = R.string.help_release_420_settings_revamp_desc, icon = HelpIcon.ofVector(Icons.Outlined.Settings)), - ReleaseHighlight(tipId = null, title = R.string.help_release_420_base_button_title, description = R.string.help_release_420_base_button_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "ai_categories", title = R.string.help_release_420_categories_refresh_title, description = R.string.help_release_420_categories_refresh_desc, icon = HelpIcon.ofVector(Icons.Outlined.AutoAwesome)), - ReleaseHighlight(tipId = "settings_selection_actions", title = R.string.help_release_420_selection_sheet_title, description = R.string.help_release_420_selection_sheet_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "timeline_group_similar", title = R.string.help_release_420_group_similar_settings_title, description = R.string.help_release_420_group_similar_settings_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "ai_models", title = R.string.help_release_420_optional_ml_title, description = R.string.help_release_420_optional_ml_desc, icon = HelpIcon.ofVector(Icons.Outlined.CloudDownload)), - ReleaseHighlight(tipId = null, title = R.string.help_release_420_bugfixes_title, description = R.string.help_release_420_bugfixes_desc, icon = HelpIcon.ofVector(Icons.Outlined.BugReport)) - ) - ), - ReleaseNotes( - versionName = "4.1.3", - versionCode = 41301, - releaseDate = "2026-04-10", - highlights = listOf( - ReleaseHighlight(tipId = "action_view_all_metadata", title = R.string.help_release_413_metadata_viewer_title, description = R.string.help_release_413_metadata_viewer_desc, icon = HelpIcon.ofVector(Icons.Outlined.EditNote)), - ReleaseHighlight(tipId = "edit_crop", title = R.string.help_release_413_remade_editor_title, description = R.string.help_release_413_remade_editor_desc, icon = HelpIcon.ofVector(Icons.Outlined.Edit)), - ReleaseHighlight(tipId = "location_browse", title = R.string.help_release_413_maplibre_title, description = R.string.help_release_413_maplibre_desc, icon = HelpIcon.ofVector(Icons.Outlined.LocationOn)) - ) - ), - ReleaseNotes( - versionName = "4.1.2", - versionCode = 41202, - releaseDate = "2026-03-15", - highlights = listOf( - ReleaseHighlight(tipId = "timeline_group_similar", title = R.string.help_release_412_group_similar_title, description = R.string.help_release_412_group_similar_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "viewer_default_editor", title = R.string.help_release_412_default_editor_title, description = R.string.help_release_412_default_editor_desc, icon = HelpIcon.ofVector(Icons.Outlined.Edit)), - ReleaseHighlight(tipId = "timeline_gif_animation", title = R.string.help_release_412_gif_animations_title, description = R.string.help_release_412_gif_animations_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)) - ) - ), - ReleaseNotes( - versionName = "4.1.1", - versionCode = 41101, - releaseDate = "2026-02-15", - highlights = listOf( - ReleaseHighlight(tipId = "edit_backups", title = R.string.help_release_411_edit_backups_title, description = R.string.help_release_411_edit_backups_desc, icon = HelpIcon.ofVector(Icons.Outlined.SettingsBackupRestore)), - ReleaseHighlight(tipId = "basics_copy_clipboard", title = R.string.help_release_411_copy_clipboard_title, description = R.string.help_release_411_copy_clipboard_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "edit_markup", title = R.string.help_release_411_enhanced_markup_title, description = R.string.help_release_411_enhanced_markup_desc, icon = HelpIcon.ofVector(Icons.Outlined.Draw)), - ReleaseHighlight(tipId = "timeline_fav_icon", title = R.string.help_release_411_fav_icon_title, description = R.string.help_release_411_fav_icon_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)) - ) - ), - ReleaseNotes( - versionName = "4.1.0", - versionCode = 41001, - releaseDate = "2026-01-20", - highlights = listOf( - ReleaseHighlight(tipId = "search_ai", title = R.string.help_release_410_ai_search_title, description = R.string.help_release_410_ai_search_desc, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch)), - ReleaseHighlight(tipId = "ai_categories", title = R.string.help_release_410_smart_categories_title, description = R.string.help_release_410_smart_categories_desc, icon = HelpIcon.ofVector(Icons.Outlined.AutoAwesome)), - ReleaseHighlight(tipId = "personalize_colors", title = R.string.help_release_410_color_palette_title, description = R.string.help_release_410_color_palette_desc, icon = HelpIcon.ofVector(Icons.Outlined.Palette)), - ReleaseHighlight(tipId = "view_panorama", title = R.string.help_release_410_panorama_title, description = R.string.help_release_410_panorama_desc, icon = HelpIcon.ofVector(Icons.Outlined.Panorama)), - ReleaseHighlight(tipId = "view_motion_photo", title = R.string.help_release_410_motion_photos_title, description = R.string.help_release_410_motion_photos_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)) - ) - ), - ReleaseNotes( - versionName = "4.0.0", - versionCode = 40001, - releaseDate = "2025-09-01", - highlights = listOf( - ReleaseHighlight(tipId = "location_browse", title = R.string.help_release_400_location_browsing_title, description = R.string.help_release_400_location_browsing_desc, icon = HelpIcon.ofVector(Icons.Outlined.LocationOn)), - ReleaseHighlight(tipId = "albums_pin", title = R.string.help_release_400_custom_thumbnails_title, description = R.string.help_release_400_custom_thumbnails_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "gesture_selection", title = R.string.help_release_400_selection_sheet_title, description = R.string.help_release_400_selection_sheet_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "nav_search", title = R.string.help_release_400_search_title, description = R.string.help_release_400_search_desc, icon = HelpIcon.ofVector(Icons.Outlined.ImageSearch)), - ReleaseHighlight(tipId = "nav_albums", title = R.string.help_release_400_list_albums_title, description = R.string.help_release_400_list_albums_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)), - ReleaseHighlight(tipId = "view_lock_image", title = R.string.help_release_400_lock_images_title, description = R.string.help_release_400_lock_images_desc, icon = HelpIcon.ofVector(Icons.Outlined.Lock)), - ReleaseHighlight(tipId = "vault_video_streaming", title = R.string.help_release_400_vault_playback_title, description = R.string.help_release_400_vault_playback_desc, icon = HelpIcon.ofVector(Icons.Outlined.Lock)), - ReleaseHighlight(tipId = "basics_timeline", title = R.string.help_release_400_ui_refresh_title, description = R.string.help_release_400_ui_refresh_desc, icon = HelpIcon.ofVector(Icons.Outlined.Collections)) - ) - ) - ) // endregion } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpTip.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpTip.kt index 1cca29fed4..e330f80283 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpTip.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/HelpTip.kt @@ -8,8 +8,7 @@ package com.dot.gallery.feature_node.presentation.help.data import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.material.icons.Icons -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R +import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.AccessibilityNew import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Brush @@ -19,13 +18,9 @@ import androidx.compose.material.icons.outlined.Colorize import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.GridView -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.ImageSearch import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.LocationOn -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Navigation -import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.PhotoLibrary import androidx.compose.material.icons.outlined.PlayCircleOutline @@ -37,25 +32,26 @@ import androidx.compose.material.icons.outlined.Tune import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import com.dot.gallery.R @Immutable data class HelpTip( val id: String, - @StringRes val title: Int, - @StringRes val subtitle: Int, + @param:StringRes val title: Int, + @param:StringRes val subtitle: Int, val icon: HelpIcon, val category: HelpCategory, val pages: List, val deepLink: String? = null, - val sinceVersion: String? = null ) @Immutable data class TutorialPage( - @StringRes val title: Int, - @StringRes val description: Int, + @param:StringRes val title: Int, + @param:StringRes val description: Int, val steps: List = emptyList(), - @StringRes val actionLabel: Int = 0, + @param:StringRes val actionLabel: Int = 0, val actionRoute: String? = null, val previewType: PreviewType = PreviewType.NONE ) @@ -63,7 +59,7 @@ data class TutorialPage( @Immutable data class HelpIcon( val vector: ImageVector? = null, - @DrawableRes val drawableRes: Int? = null + @param:DrawableRes val drawableRes: Int? = null ) { companion object { fun ofVector(vector: ImageVector) = HelpIcon(vector = vector) @@ -80,13 +76,11 @@ enum class PreviewType { PHOTO_EDITOR_CROP, PHOTO_EDITOR_FILTERS, PHOTO_EDITOR_MARKUP, - VAULT_LOCK, SEARCH_BAR, FAVORITES_GRID, TRASH_GRID, THEME_PICKER, COLOR_PALETTE, - LOCATION_MAP, EXIF_VIEWER, PINCH_ZOOM_GRID, COLLECTION_VIEW, @@ -96,7 +90,6 @@ enum class PreviewType { } enum class HelpCategory { - WHATS_NEW, GET_STARTED_BASICS, GET_STARTED_NAVIGATION, GET_STARTED_PERSONALIZATION, @@ -108,14 +101,11 @@ enum class HelpCategory { SEARCH, AI_FEATURES, ALBUMS, - VAULT, FAVORITES_TRASH, - LOCATIONS, METADATA, SETTINGS_APPEARANCE, SETTINGS_GENERAL, SETTINGS_NAVIGATION, - SETTINGS_SMART, GESTURES, SELECTION_ACTIONS, ACCESSIBILITY @@ -123,7 +113,6 @@ enum class HelpCategory { @Composable fun HelpCategory.displayTitle(): String = when (this) { - HelpCategory.WHATS_NEW -> stringResource(R.string.help_whats_new) HelpCategory.GET_STARTED_BASICS -> stringResource(R.string.help_cat_basics) HelpCategory.GET_STARTED_NAVIGATION -> stringResource(R.string.help_cat_navigation) HelpCategory.GET_STARTED_PERSONALIZATION -> stringResource(R.string.help_cat_personalization) @@ -135,21 +124,17 @@ fun HelpCategory.displayTitle(): String = when (this) { HelpCategory.SEARCH -> stringResource(R.string.help_cat_search) HelpCategory.AI_FEATURES -> stringResource(R.string.help_cat_ai) HelpCategory.ALBUMS -> stringResource(R.string.help_cat_albums) - HelpCategory.VAULT -> stringResource(R.string.help_cat_vault) HelpCategory.FAVORITES_TRASH -> stringResource(R.string.help_cat_fav_trash) - HelpCategory.LOCATIONS -> stringResource(R.string.help_cat_locations) HelpCategory.METADATA -> stringResource(R.string.help_cat_metadata) HelpCategory.SETTINGS_APPEARANCE -> stringResource(R.string.help_cat_settings_appearance) HelpCategory.SETTINGS_GENERAL -> stringResource(R.string.help_cat_settings_general) HelpCategory.SETTINGS_NAVIGATION -> stringResource(R.string.help_cat_settings_navigation) - HelpCategory.SETTINGS_SMART -> stringResource(R.string.help_cat_settings_smart) HelpCategory.GESTURES -> stringResource(R.string.help_cat_gestures) HelpCategory.SELECTION_ACTIONS -> stringResource(R.string.help_cat_selection_actions) HelpCategory.ACCESSIBILITY -> stringResource(R.string.help_cat_accessibility) } fun HelpCategory.icon(): ImageVector = when (this) { - HelpCategory.WHATS_NEW -> Icons.Outlined.NewReleases HelpCategory.GET_STARTED_BASICS -> Icons.AutoMirrored.Outlined.HelpOutline HelpCategory.GET_STARTED_NAVIGATION -> Icons.Outlined.Navigation HelpCategory.GET_STARTED_PERSONALIZATION -> Icons.Outlined.Palette @@ -161,14 +146,11 @@ fun HelpCategory.icon(): ImageVector = when (this) { HelpCategory.SEARCH -> Icons.Outlined.Search HelpCategory.AI_FEATURES -> Icons.Outlined.AutoAwesome HelpCategory.ALBUMS -> Icons.Outlined.Collections - HelpCategory.VAULT -> Icons.Outlined.Lock HelpCategory.FAVORITES_TRASH -> Icons.Outlined.FavoriteBorder - HelpCategory.LOCATIONS -> Icons.Outlined.LocationOn HelpCategory.METADATA -> Icons.Outlined.Info HelpCategory.SETTINGS_APPEARANCE -> Icons.Outlined.Colorize HelpCategory.SETTINGS_GENERAL -> Icons.Outlined.Settings HelpCategory.SETTINGS_NAVIGATION -> Icons.Outlined.Explore - HelpCategory.SETTINGS_SMART -> Icons.Outlined.AutoAwesome HelpCategory.GESTURES -> Icons.Outlined.Swipe HelpCategory.SELECTION_ACTIONS -> Icons.Outlined.ChecklistRtl HelpCategory.ACCESSIBILITY -> Icons.Outlined.AccessibilityNew diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/ReleaseNotes.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/ReleaseNotes.kt deleted file mode 100644 index 64a797a27e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/data/ReleaseNotes.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.help.data - -import androidx.annotation.StringRes -import androidx.compose.runtime.Immutable - -@Immutable -data class ReleaseNotes( - val versionName: String, - val versionCode: Int, - val releaseDate: String, - val highlights: List -) - -@Immutable -data class ReleaseHighlight( - val tipId: String? = null, - @StringRes val title: Int, - @StringRes val description: Int, - val icon: HelpIcon -) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/previews/HelpPreviewRegistry.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/previews/HelpPreviewRegistry.kt index cbcdff3673..da5dd40b5b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/previews/HelpPreviewRegistry.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/help/previews/HelpPreviewRegistry.kt @@ -52,22 +52,17 @@ import com.dot.gallery.R import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.presentation.components.FilterOption import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import kotlinx.coroutines.flow.MutableStateFlow -import com.dot.gallery.feature_node.presentation.albums.AlbumsScreen -import com.dot.gallery.feature_node.presentation.classifier.CategoriesScreen -import com.dot.gallery.feature_node.presentation.exif.MetadataViewScreen -import com.dot.gallery.feature_node.presentation.edit.EditScreen2 import com.dot.gallery.feature_node.domain.model.editor.DrawMode import com.dot.gallery.feature_node.domain.model.editor.DrawType import com.dot.gallery.feature_node.domain.model.editor.PathProperties -import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen -import com.dot.gallery.feature_node.presentation.vault.VaultDisplay +import com.dot.gallery.feature_node.presentation.albums.AlbumsScreen +import com.dot.gallery.feature_node.presentation.classifier.CategoriesScreen +import com.dot.gallery.feature_node.presentation.edit.EditScreen2 +import com.dot.gallery.feature_node.presentation.exif.MetadataViewScreen import com.dot.gallery.feature_node.presentation.favorites.FavoriteScreen import com.dot.gallery.feature_node.presentation.help.data.HelpMockData import com.dot.gallery.feature_node.presentation.help.data.PreviewType -import com.dot.gallery.feature_node.presentation.location.ListLocationsContent +import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen import com.dot.gallery.feature_node.presentation.settings.SettingsScreen import com.dot.gallery.feature_node.presentation.settings.subsettings.ColorPaletteScreen import com.dot.gallery.feature_node.presentation.timeline.TimelineScreen @@ -85,7 +80,7 @@ import com.dot.gallery.ui.core.icons.Albums * [SharedTransitionScope] + [AnimatedContentScope]. * * Tier 2/3 previews use simplified mockups for components that are - * too heavy to render in a help screen context (editor, vault, etc.). + * too heavy to render in a help screen context (editor, etc.). */ @Composable fun HelpPreview( @@ -105,11 +100,9 @@ fun HelpPreview( PreviewType.PHOTO_EDITOR_CROP -> EditorCropPreviewMini(modifier) PreviewType.PHOTO_EDITOR_FILTERS -> EditorFiltersPreviewMini(modifier) PreviewType.PHOTO_EDITOR_MARKUP -> EditorMarkupPreviewMini(modifier) - PreviewType.VAULT_LOCK -> VaultPreviewMini(modifier) PreviewType.THEME_PICKER -> ThemePickerPreviewMini(modifier) PreviewType.COLOR_PALETTE -> ColorPalettePreviewMini(modifier) PreviewType.PINCH_ZOOM_GRID -> PinchZoomPreviewMini(modifier) - PreviewType.LOCATION_MAP -> LocationMapPreviewMini(modifier) PreviewType.NAV_BAR_PREVIEW -> NavBarPreviewMini(modifier) PreviewType.SETTINGS_GENERAL -> SettingsPreviewMini(modifier) PreviewType.COLLECTION_VIEW -> AlbumGridPreviewMini(modifier) @@ -337,7 +330,6 @@ private fun ViewerPreviewMini(modifier: Modifier = Modifier) { mediaState = remember { mutableStateOf(HelpMockData.MOCK_MEDIA_STATE) }, metadataState = remember { mutableStateOf(HelpMockData.MOCK_METADATA_STATE) }, albumsState = remember { mutableStateOf(HelpMockData.MOCK_ALBUM_STATE) }, - vaultState = remember { mutableStateOf(VaultState()) }, sharedTransitionScope = sharedScope, animatedContentScope = animScope, ) @@ -397,7 +389,6 @@ private fun EditorPreviewMini(modifier: Modifier = Modifier) { currentPathProperty = PathProperties(), currentPath = Path(), onClose = {}, - onOverride = {}, onSaveCopy = {}, onAdjustItemLongClick = {}, onAdjustmentChange = {}, @@ -448,48 +439,6 @@ private fun EditorMarkupPreviewMini(modifier: Modifier = Modifier) { EditorPreviewMini(modifier) } -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -private fun VaultPreviewMini(modifier: Modifier = Modifier) { - val mockVault = remember { Vault(name = "My Vault") } - val mockVaultState = remember { - mutableStateOf(VaultState(vaults = listOf(mockVault), isLoading = false)) - } - val mockCurrentVault = remember { MutableStateFlow(mockVault) } - val mockMediaStateFlow = remember { MutableStateFlow(HelpMockData.MOCK_MEDIA_STATE) } - val animation = rememberPreviewAnimation(stepCount = 2) - PreviewFrame(modifier, applyPadding = false) { - AutoScrollBox( - scrollProgress = animation.stepProgress, - modifier = Modifier.matchParentSize() - ) { - PreviewScreenProvider { sharedScope, animScope -> - VaultDisplay( - globalNavigateUp = {}, - vaultState = mockVaultState, - currentVault = mockCurrentVault, - createMediaState = { mockMediaStateFlow }, - onCreateVaultClick = {}, - deleteLeftovers = { _, _ -> }, - setVault = {}, - deleteVault = {}, - restoreVault = {}, - workerProgress = remember { MutableStateFlow(0f) }, - workerIsRunning = remember { MutableStateFlow(false) }, - sharedTransitionScope = sharedScope, - animatedContentScope = animScope, - metadataState = remember { mutableStateOf(HelpMockData.MOCK_METADATA_STATE) }, - ) - } - } - SwipeGestureOverlay( - modifier = Modifier.matchParentSize(), - progress = animation.stepProgress, - direction = SwipeDirection.UP - ) - } -} - @Composable private fun ThemePickerPreviewMini(modifier: Modifier = Modifier) { val animation = rememberPreviewAnimation(stepCount = 2) @@ -530,29 +479,6 @@ private fun ColorPalettePreviewMini(modifier: Modifier = Modifier) { } } -@Composable -private fun LocationMapPreviewMini(modifier: Modifier = Modifier) { - val animation = rememberPreviewAnimation(stepCount = 2) - PreviewFrame(modifier, applyPadding = false) { - AutoScrollBox( - scrollProgress = animation.stepProgress, - modifier = Modifier.matchParentSize() - ) { - PreviewScreenProvider { _, _ -> - ListLocationsContent( - metadataState = remember { mutableStateOf(HelpMockData.MOCK_METADATA_STATE) }, - locations = remember { HelpMockData.MOCK_LOCATIONS }, - ) - } - } - SwipeGestureOverlay( - modifier = Modifier.matchParentSize(), - progress = animation.stepProgress, - direction = SwipeDirection.UP - ) - } -} - @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun NavBarPreviewMini(modifier: Modifier = Modifier) { @@ -663,5 +589,3 @@ private fun PreviewFrame( content() } } - - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt index 183919f84d..794d907792 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt @@ -43,8 +43,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.SettingsEntity import com.dot.gallery.core.presentation.components.NavigationBackButton +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.ignored.setup.IgnoredOptionsSheet import com.dot.gallery.feature_node.presentation.ignored.setup.IgnoredSetupSheet import com.dot.gallery.feature_node.presentation.settings.components.AlbumPreferenceItem @@ -52,6 +52,7 @@ import com.dot.gallery.feature_node.presentation.settings.components.SettingsIte import com.dot.gallery.feature_node.presentation.settings.components.settings import com.dot.gallery.feature_node.presentation.util.PreviewHost import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.toGlideModel import kotlinx.coroutines.launch @Composable @@ -189,8 +190,8 @@ fun IgnoredContent( R.string.matched_albums, blacklistedAlbum.matchedAlbums.joinToString() ), - albumUri = primaryAlbum?.uri, - secondaryAlbumUri = secondaryAlbum?.uri, + albumUri = primaryAlbum?.toGlideModel(), + secondaryAlbumUri = secondaryAlbum?.toGlideModel(), albumLabel = primaryAlbum?.label, albumCount = primaryAlbum?.count?.toInt() ?: 0, matchedAlbumsCount = blacklistedAlbum.matchedAlbums.size, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt index 2c140e33b0..00d6eb1569 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt @@ -2,7 +2,7 @@ package com.dot.gallery.feature_node.presentation.ignored import android.os.Parcelable import androidx.compose.runtime.Immutable -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import kotlinx.parcelize.Parcelize @Immutable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt index 40d4dd4324..0296be888c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt @@ -3,16 +3,16 @@ package com.dot.gallery.feature_node.presentation.ignored import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.LocalMediaDistributor -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.matchesAlbum -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.matchesAlbum +import com.dot.gallery.feature_node.data.repository.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class IgnoredViewModel @Inject constructor( @@ -21,7 +21,7 @@ class IgnoredViewModel @Inject constructor( val blacklistState = combine( repository.getBlacklistedAlbums(), - repository.getAlbums(com.dot.gallery.feature_node.domain.util.MediaOrder.Default) + repository.getAlbums(com.dot.gallery.feature_node.data.util.MediaOrder.Default) ) { ignoredAlbums, albumsResource -> val allAlbums = albumsResource.data ?: emptyList() val updatedIgnoredAlbums = ignoredAlbums.map { ignored -> diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsSheet.kt index 41b7299aff..5a22a2065d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsSheet.kt @@ -26,9 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.ignored.setup.components.EditAlbumsStep import com.dot.gallery.feature_node.presentation.ignored.setup.components.EditConfirmationStep import com.dot.gallery.feature_node.presentation.ignored.setup.components.EditLocationStep diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsViewModel.kt index 617b3efc95..a40c03f649 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredOptionsViewModel.kt @@ -2,16 +2,16 @@ package com.dot.gallery.feature_node.presentation.ignored.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.matchesAlbum -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.matchesAlbum +import com.dot.gallery.feature_node.data.repository.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class IgnoredOptionsViewModel @Inject constructor( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupSheet.kt index 6873b41c2d..5fe39c2b2a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.outlined.Checklist import androidx.compose.material.icons.outlined.FilterNone import androidx.compose.material.icons.outlined.FolderCopy import androidx.compose.material.icons.outlined.PhotoAlbum -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -70,9 +69,10 @@ import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.ignored.setup.components.ConfirmationCard import com.dot.gallery.feature_node.presentation.ignored.setup.components.RegexExample import com.dot.gallery.feature_node.presentation.ignored.setup.components.SectionHeader @@ -80,9 +80,9 @@ import com.dot.gallery.feature_node.presentation.ignored.setup.components.Select import com.dot.gallery.feature_node.presentation.ignored.setup.components.TypeOptionCard import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.PreviewHost +import com.dot.gallery.ui.core.Icons as GalleryIcons import com.dot.gallery.ui.core.icons.RegularExpression import kotlinx.coroutines.launch -import com.dot.gallery.ui.core.Icons as GalleryIcons @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupUiState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupUiState.kt index fc8c3dc158..df9ac34fe8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupUiState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupUiState.kt @@ -1,7 +1,7 @@ package com.dot.gallery.feature_node.presentation.ignored.setup -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum // ========== Setup Sheet State ========== diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt index 9d43d7c161..08c2fade0c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt @@ -2,12 +2,14 @@ package com.dot.gallery.feature_node.presentation.ignored.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.matchesAlbum -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.matchesAlbum +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.presentation.ignored.IgnoredState import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow @@ -16,8 +18,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.UUID -import javax.inject.Inject @HiltViewModel class IgnoredSetupViewModel @Inject constructor( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt index 47c9e5cb52..f823092b4a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt @@ -1,6 +1,6 @@ package com.dot.gallery.feature_node.presentation.ignored.setup -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album sealed class IgnoredType { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumItem.kt index 6e7997e9e1..c7d818a5fd 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumItem.kt @@ -28,8 +28,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.util.PreviewHost +import com.dot.gallery.feature_node.presentation.util.toGlideModel @OptIn(ExperimentalGlideComposeApi::class) @Composable @@ -61,7 +62,7 @@ fun EditAlbumItem( ) { GlideImage( modifier = Modifier.fillMaxSize(), - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumsStep.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumsStep.kt index 1840161ec6..d58cff7f40 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumsStep.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditAlbumsStep.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -38,9 +37,10 @@ import com.dot.gallery.R import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.matchesAlbum import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.matchesAlbum import com.dot.gallery.feature_node.presentation.util.PreviewHost @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditConfirmationStep.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditConfirmationStep.kt index 13025967aa..6a890ebb42 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditConfirmationStep.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditConfirmationStep.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -44,9 +43,11 @@ import com.dot.gallery.R import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.util.PreviewHost +import com.dot.gallery.feature_node.presentation.util.toGlideModel @OptIn(ExperimentalGlideComposeApi::class, ExperimentalMaterial3Api::class) @Stable @@ -238,7 +239,7 @@ fun ConfirmationAlbumItem( ) { Column { GlideImage( - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, modifier = modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditLocationStep.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditLocationStep.kt index 98cac3525b..e5a49ed0ec 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditLocationStep.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/EditLocationStep.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -30,7 +29,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.util.PreviewHost @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/OptionsMenuStep.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/OptionsMenuStep.kt index 2fbe951c12..6a89801436 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/OptionsMenuStep.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/OptionsMenuStep.kt @@ -43,14 +43,15 @@ import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.presentation.common.components.OptionItem as OptionItemData import com.dot.gallery.feature_node.presentation.common.components.OptionLayout import com.dot.gallery.feature_node.presentation.util.PreviewHost -import com.dot.gallery.ui.core.icons.RegularExpression -import com.dot.gallery.feature_node.presentation.common.components.OptionItem as OptionItemData +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.core.Icons as GalleryIcons +import com.dot.gallery.ui.core.icons.RegularExpression @OptIn(ExperimentalGlideComposeApi::class) @Composable @@ -181,7 +182,7 @@ fun IgnoredAlbumThumbnail( .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { GlideImage( - model = secondaryAlbum.uri, + model = secondaryAlbum.toGlideModel(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() @@ -200,7 +201,7 @@ fun IgnoredAlbumThumbnail( .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) { GlideImage( - model = primaryAlbum.uri, + model = primaryAlbum.toGlideModel(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() @@ -235,7 +236,7 @@ fun IgnoredAlbumThumbnail( when { primaryAlbum != null -> { GlideImage( - model = primaryAlbum.uri, + model = primaryAlbum.toGlideModel(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectableAlbumItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectableAlbumItem.kt index 3bc5454bb0..d7303e17cb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectableAlbumItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectableAlbumItem.kt @@ -31,9 +31,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.PreviewHost +import com.dot.gallery.feature_node.presentation.util.toGlideModel /** * A selectable album grid item with thumbnail, selection indicator, @@ -77,7 +78,7 @@ fun SelectableAlbumItem( ) ) { GlideImage( - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectedAlbumChip.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectedAlbumChip.kt index 202abe8d67..2ec248eab0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectedAlbumChip.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/components/SelectedAlbumChip.kt @@ -23,9 +23,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.PreviewHost +import com.dot.gallery.feature_node.presentation.util.toGlideModel /** * A chip showing a selected album with thumbnail and remove capability. @@ -47,7 +48,7 @@ fun SelectedAlbumChip( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { GlideImage( - model = album.uri, + model = album.toGlideModel(), contentDescription = album.label, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt index 983957064c..b45a58f5e5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -66,21 +67,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.Settings.Misc.rememberNoClassification import com.dot.gallery.core.ml.ModelStatus import com.dot.gallery.core.navigate import com.dot.gallery.core.util.SdkCompat -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.library.components.LibrarySmallItem -import com.dot.gallery.feature_node.presentation.library.components.MapPreviewCard import com.dot.gallery.feature_node.presentation.library.components.dashedBorder import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.search.MainSearchBar @@ -88,7 +86,7 @@ import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.categorySharedElement -import com.dot.gallery.ui.core.icons.Encrypted +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.BlackScrim import com.dot.gallery.ui.theme.WhiterBlackScrim import com.dot.gallery.ui.theme.isDarkTheme @@ -98,7 +96,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import com.dot.gallery.ui.core.Icons as GalleryIcons @OptIn( ExperimentalSharedTransitionApi::class, ExperimentalHazeMaterialsApi::class, @@ -126,9 +123,6 @@ fun LibraryScreen( } } - val locations by viewModel.locations.collectAsStateWithLifecycle() - val geoMedia by viewModel.geoMedia.collectAsStateWithLifecycle() - val indicatorState by viewModel.indicatorState.collectAsStateWithLifecycle() // New category system @@ -136,15 +130,8 @@ fun LibraryScreen( val totalCategoryCount by viewModel.totalCategoryCount.collectAsStateWithLifecycle() val noCategoriesFound by rememberedDerivedState { topCategories.isEmpty() } - // Locations - val noLocationsFound by rememberedDerivedState { locations.isEmpty() } - val totalLocationsCount by rememberedDerivedState { locations.size } - val mapsEnabled = remember { BuildConfig.MAPS_ENABLED } - val isDark = isDarkTheme() - val modelStatus by viewModel.modelStatus.collectAsStateWithLifecycle() - val hasInternet = viewModel.hasInternetPermission - var noClassification by rememberNoClassification() + val analysisSettings by viewModel.analysisSettings.collectAsStateWithLifecycle() Scaffold( modifier = Modifier.padding( @@ -278,17 +265,6 @@ fun LibraryScreen( Alignment.CenterHorizontally ) ) { - LibrarySmallItem( - title = stringResource(R.string.vault), - icon = GalleryIcons.Encrypted, - contentColor = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .weight(1f) - .clickable { - eventHandler.navigate(Screen.VaultScreen()) - }, - contentDescription = stringResource(R.string.vault) - ) LibrarySmallItem( title = stringResource(R.string.ignored), icon = Icons.Outlined.VisibilityOff, @@ -303,134 +279,10 @@ fun LibraryScreen( } } - // Locations section - if (!noLocationsFound) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "LocationsHeader" - ) { - if (mapsEnabled) { - val latest = geoMedia.firstOrNull() - MapPreviewCard( - modifier = Modifier - .animateItem() - .pinchItem(key = "LocationsHeader") - .padding(horizontal = 16.dp) - .padding(top = 8.dp) - .clip(RoundedCornerShape(24.dp)) - .clickable { - eventHandler.navigate(Screen.LocationsScreen()) - }, - latestMedia = latest?.media, - latitude = latest?.latitude, - longitude = latest?.longitude, - isDark = isDark - ) - } else { - Column( - modifier = Modifier - .animateItem() - .pinchItem(key = "LocationsHeader") - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - LibrarySmallItem( - title = stringResource(R.string.locations), - icon = null, - contentColor = MaterialTheme.colorScheme.onSurface, - containerColor = MaterialTheme.colorScheme.surface, - useIndicator = true, - indicatorCounter = totalLocationsCount, - modifier = Modifier - .fillMaxWidth() - .clickable { - eventHandler.navigate(Screen.LocationsScreen()) - } - ) - } - } - } - // Locations carousel - item( - span = { GridItemSpan(maxLineSpan) }, - key = "LocationsList" - ) { - LazyRow( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp) - .clip(RoundedCornerShape(16.dp)), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - items( - items = locations, - key = { it.toString() } - ) { (media, location) -> - with(sharedTransitionScope) { - val isDarkTheme = isDarkTheme() - val allowBlur by rememberAllowBlur() - val followTheme = remember(allowBlur) { !allowBlur } - val gradientColor by animateColorAsState( - if (followTheme) { - if (isDarkTheme) BlackScrim else WhiterBlackScrim - } else BlackScrim, - ) - Box( - modifier = Modifier - .width(164.dp) - .height(256.dp) - .clip(RoundedCornerShape(24.dp)) - .clickable { - val gpsLocationNameCity = - location.substringBefore(",") - val gpsLocationNameCountry = - location.substringAfterLast(", ") - eventHandler.navigate( - Screen.LocationTimelineScreen.location( - gpsLocationNameCity = gpsLocationNameCity, - gpsLocationNameCountry = gpsLocationNameCountry - ) - ) - }, - ) { - GlideImage( - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - model = media.getUri(), - contentDescription = location, - requestBuilderTransform = { - it.signature(GlideInvalidation.signature(media)) - } - ) - Text( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - gradientColor - ) - ) - ) - .padding(24.dp), - text = location, - style = MaterialTheme.typography.titleMedium, - color = Color.White, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - overflow = TextOverflow.MiddleEllipsis - ) - } - } - } - } - } - } - - if (hasInternet && !noClassification) { + if ( + analysisSettings.analysisEnabled && + analysisSettings.categoryClassificationEnabled + ) { if (!noCategoriesFound) { // "See all categories" header below carousel item( @@ -515,7 +367,7 @@ fun LibraryScreen( GlideImage( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - model = thumbnailMedia.getUri(), + model = thumbnailMedia.toGlideModel(), contentDescription = category.name, requestBuilderTransform = { it.signature( @@ -567,8 +419,9 @@ fun LibraryScreen( maxLines = 1 ) Text( - text = stringResource( - R.string.category_media_count, + text = pluralStringResource( + id = R.plurals.item_count, + count = category.mediaCount, category.mediaCount ), style = MaterialTheme.typography.bodySmall, @@ -651,4 +504,4 @@ fun NoCategories( style = MaterialTheme.typography.titleMedium.copy(brush = brush), ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt index 6ef69f4cc0..c92f69c176 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt @@ -2,26 +2,26 @@ package com.dot.gallery.feature_node.presentation.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.work.WorkManager import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.Resource import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.core.ml.ModelStatus import com.dot.gallery.core.util.SdkCompat -import com.dot.gallery.core.workers.startCategoryClassification -import com.dot.gallery.feature_node.data.data_source.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.MediaOrder import com.dot.gallery.feature_node.domain.model.LibraryIndicatorState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSettings import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject /** * Data class for category with its thumbnail media @@ -32,22 +32,23 @@ data class CategoryMedia( ) @HiltViewModel -class LibraryViewModel @Inject constructor( +class LibraryViewModel @Inject internal constructor( private val repository: MediaRepository, private val mediaDistributor: MediaDistributor, - private val workManager: WorkManager, + private val aiMediaAnalysis: AiMediaAnalysis, private val modelManager: ModelManager ) : ViewModel() { - val hasInternetPermission: Boolean get() = modelManager.hasInternetPermission - val modelStatus: StateFlow = modelManager.status - val locations = mediaDistributor.locationsMediaFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - - val geoMedia = mediaDistributor.geoMediaFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + internal val analysisSettings: StateFlow = aiMediaAnalysis.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = AiMediaAnalysisSettings( + analysisEnabled = false, + categoryClassificationEnabled = true, + ), + ) val indicatorState = combine( if (SdkCompat.supportsTrash) repository.getTrashed() else flowOf(Resource.Success(emptyList())), @@ -88,11 +89,4 @@ class LibraryViewModel @Inject constructor( .map { it.groupBy { it.category!! }.toSortedMap() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyMap()) - /** - * Start the category classification using the new CLIP-based system - */ - fun startClassification() { - workManager.startCategoryClassification() - } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/MapPreviewCard.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/MapPreviewCard.kt deleted file mode 100644 index 43bbf2129d..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/MapPreviewCard.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.library.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.util.GlideInvalidation -import kotlin.math.cos -import kotlin.math.ln -import kotlin.math.tan - -/** - * A card showing a static map preview with a circular photo thumbnail, - * used in the Library screen to replace the plain "Locations" header. - */ -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -fun MapPreviewCard( - modifier: Modifier = Modifier, - latestMedia: Media.UriMedia?, - latitude: Double?, - longitude: Double?, - isDark: Boolean, -) { - val mapTileUrl = remember(latitude, longitude, isDark) { - val lat = latitude ?: 46.77 - val lon = longitude ?: 23.59 - val zoom = 8 - val tileX = lonToTileX(lon, zoom) - val tileY = latToTileY(lat, zoom) - if (isDark) { - "https://a.basemaps.cartocdn.com/dark_all/$zoom/$tileX/$tileY@2x.png" - } else { - "https://a.basemaps.cartocdn.com/rastertiles/voyager/$zoom/$tileX/$tileY@2x.png" - } - } - - Box( - modifier = modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(24.dp)) - ) { - // Map tile background - GlideImage( - model = mapTileUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - requestBuilderTransform = { - it.diskCacheStrategy(DiskCacheStrategy.ALL) - } - ) - - // Circular photo thumbnail at center - if (latestMedia != null) { - GlideImage( - model = latestMedia.getUri(), - contentDescription = null, - modifier = Modifier - .size(72.dp) - .clip(CircleShape) - .align(Alignment.Center), - contentScale = ContentScale.Crop, - requestBuilderTransform = { - it.signature(GlideInvalidation.signature(latestMedia)) - .diskCacheStrategy(DiskCacheStrategy.ALL) - } - ) - } - } -} - -// Slippy-map tile math -private fun lonToTileX(lon: Double, zoom: Int): Int { - return ((lon + 180.0) / 360.0 * (1 shl zoom)).toInt() -} - -private fun latToTileY(lat: Double, zoom: Int): Int { - val latRad = Math.toRadians(lat) - return ((1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / Math.PI) / 2.0 * (1 shl zoom)).toInt() -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationTimelineScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationTimelineScreen.kt deleted file mode 100644 index a120cf83b7..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationTimelineScreen.kt +++ /dev/null @@ -1,212 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.BuildConfig -import com.dot.gallery.core.Constants.cellsList -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.core.Settings.Album.rememberHideTimelineOnAlbum -import com.dot.gallery.core.Settings.Misc.rememberGridSize -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.EmptyMedia -import com.dot.gallery.core.presentation.components.NavigationButton -import com.dot.gallery.core.presentation.components.SelectionSheet -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.MediaGridView -import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState -import com.dot.gallery.feature_node.presentation.library.components.MapPreviewCard -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.feature_node.presentation.util.selectedMedia -import com.dot.gallery.ui.theme.isDarkTheme -import dev.chrisbanes.haze.LocalHazeStyle -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, - ExperimentalFoundationApi::class -) -@Composable -fun LocationTimelineScreen( - gpsLocationNameCity: String, - gpsLocationNameCountry: String, - mediaState: State>, - latestGeoMedia: GeoMedia?, - metadataState: State, - paddingValues: PaddingValues, - isScrolling: MutableState, - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, -) { - var canScroll by rememberSaveable { mutableStateOf(true) } - var lastCellIndex by rememberGridSize() - val eventHandler = LocalEventHandler.current - val selector = LocalMediaSelector.current - val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() - - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - state = rememberTopAppBarState(), - canScroll = { canScroll }, - flingAnimationSpec = null - ) - - val dpCacheWindow = remember { - LazyLayoutCacheWindow(ahead = 200.dp, behind = 100.dp) - } - val pinchState = rememberGridPinchZoomState( - cellsList = cellsList, - initialCellsIndex = lastCellIndex, - gridState = rememberLazyGridState( - cacheWindow = dpCacheWindow - ) - ) - - LaunchedEffect(pinchState.isZooming) { - withContext(Dispatchers.IO) { - canScroll = !pinchState.isZooming - lastCellIndex = cellsList.indexOf(pinchState.currentCells) - } - } - Box( - modifier = Modifier - .padding( - start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), - end = paddingValues.calculateEndPadding(LocalLayoutDirection.current) - ) - ) { - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - modifier = Modifier.hazeEffect( - state = LocalHazeState.current, - style = LocalHazeStyle.current - ), - title = { - TwoLinedDateToolbarTitle( - albumName = "$gpsLocationNameCity, $gpsLocationNameCountry", - dateHeader = mediaState.value.dateHeader - ) - }, - navigationIcon = { - NavigationButton( - albumId = -1, - target = "location_${gpsLocationNameCity}_$gpsLocationNameCountry", - alwaysGoBack = true, - ) - }, - actions = { - - }, - scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - ) { it -> - GridPinchZoomLayout( - state = pinchState, - modifier = Modifier.hazeSource(LocalHazeState.current), - indicatorTopPadding = it.calculateTopPadding() + 16.dp, - ) { - val hideTimelineOnAlbum by rememberHideTimelineOnAlbum() - MediaGridView( - mediaState = mediaState, - metadataState = metadataState, - allowSelection = true, - showSearchBar = false, - enableStickyHeaders = !hideTimelineOnAlbum, - paddingValues = PaddingValues( - top = it.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 128.dp - ), - canScroll = canScroll, - allowHeaders = !hideTimelineOnAlbum, - showMonthlyHeader = false, - aboveGridContent = if (BuildConfig.MAPS_ENABLED && latestGeoMedia != null) { - { - val isDark = isDarkTheme() - MapPreviewCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .clickable { - eventHandler.navigate(Screen.LocationsScreen.withMediaId(latestGeoMedia.mediaId)) - }, - latestMedia = latestGeoMedia.media, - latitude = latestGeoMedia.latitude, - longitude = latestGeoMedia.longitude, - isDark = isDark - ) - } - } else null, - isScrolling = isScrolling, - emptyContent = { EmptyMedia(modifier = Modifier.padding(paddingValues)) }, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope - ) { - eventHandler.navigate(Screen.MediaViewScreen.idAndLocation(it.id, gpsLocationNameCity, gpsLocationNameCountry)) - } - } - } - val selectedMediaList by selectedMedia( - media = mediaState.value.media, - selectedSet = selectedMedia - ) - SelectionSheet( - modifier = Modifier - .align(Alignment.BottomEnd), - allMedia = mediaState.value, - selectedMedia = selectedMediaList - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsScreen.kt deleted file mode 100644 index 97f22c5a8c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsScreen.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.LocationOn -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.dot.gallery.R -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.library.components.LibrarySmallItem -import com.dot.gallery.feature_node.presentation.util.GlideInvalidation -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.Screen -import dev.chrisbanes.haze.LocalHazeStyle -import dev.chrisbanes.haze.hazeEffect - -internal sealed interface MapGridItem { - data class Header(val date: String) : MapGridItem - data class MediaCell(val geoMedia: GeoMedia) : MapGridItem -} - -@Composable -fun LocationsScreen( - metadataState: State, - locations: List = emptyList(), - geoMedia: List = emptyList(), - initialMediaId: Long = -1L, -) { - MapLocationsContent(metadataState = metadataState, locations = locations, geoMedia = geoMedia, initialMediaId = initialMediaId) -} - -@Suppress("DerivedStateOfCandidate") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun ListLocationsContent( - metadataState: State, - locations: List = emptyList(), -) { - val eventHandler = LocalEventHandler.current - - val grouped = remember(locations) { - locations.groupBy { it.location } - } - - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - state = rememberTopAppBarState(), - flingAnimationSpec = null - ) - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - modifier = Modifier.hazeEffect( - state = LocalHazeState.current, - style = LocalHazeStyle.current - ), - title = { - Text(text = stringResource(R.string.locations)) - }, - navigationIcon = { - NavigationBackButton() - }, - scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - } - ) { paddingValues -> - if (grouped.isEmpty() && !metadataState.value.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.no_locations_found), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - LazyColumn( - contentPadding = PaddingValues( - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 128.dp, - start = 8.dp, - end = 8.dp - ), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items( - items = grouped.entries.toList(), - key = { it.key } - ) { (location, mediaList) -> - val parts = location.split(",").map { it.trim() } - val city = parts.getOrElse(0) { "" } - val country = parts.getOrElse(1) { "" } - LibrarySmallItem( - modifier = Modifier.clickable { - if (city.isNotEmpty() && country.isNotEmpty()) { - eventHandler.navigate( - Screen.LocationTimelineScreen.location(city, country) - ) - } - }, - title = location, - subtitle = "${mediaList.size} ${if (mediaList.size == 1) "item" else "items"}", - icon = Icons.Outlined.LocationOn, - useIndicator = true, - indicatorCounter = mediaList.size - ) - } - } - } - - if (metadataState.value.isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.BottomEnd - ) { - CircularProgressIndicator( - modifier = Modifier - .padding(16.dp) - .size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } - } - } -} - -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -internal fun MediaGridPanel( - modifier: Modifier = Modifier, - gridState: LazyGridState, - gridItems: List, - stringToday: String, - stringYesterday: String, - onMediaClick: (GeoMedia) -> Unit, -) { - LazyVerticalGrid( - state = gridState, - columns = GridCells.Fixed(4), - modifier = modifier, - contentPadding = PaddingValues(start = 2.dp, end = 2.dp, bottom = 80.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items( - items = gridItems, - key = { item -> - when (item) { - is MapGridItem.Header -> "header_${item.date}" - is MapGridItem.MediaCell -> "media_${item.geoMedia.mediaId}" - } - }, - span = { item -> - GridItemSpan( - when (item) { - is MapGridItem.Header -> maxLineSpan - is MapGridItem.MediaCell -> 1 - } - ) - }, - contentType = { item -> - when (item) { - is MapGridItem.Header -> "header" - is MapGridItem.MediaCell -> "media" - } - } - ) { item -> - when (item) { - is MapGridItem.Header -> { - val displayDate = remember(item.date) { - item.date - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - } - Text( - text = displayDate, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 24.dp - ) - ) - } - - is MapGridItem.MediaCell -> { - GlideImage( - model = item.geoMedia.media.getUri(), - contentDescription = item.geoMedia.media.label, - contentScale = ContentScale.Crop, - modifier = Modifier - .aspectRatio(1f) - .clickable { onMediaClick(item.geoMedia) }, - requestBuilderTransform = { - it.centerCrop() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .signature(GlideInvalidation.signature(item.geoMedia.media)) - } - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsViewModel.kt deleted file mode 100644 index 325814bfd3..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/location/LocationsViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.dot.gallery.feature_node.presentation.location - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.MediaDistributor -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.MediaState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -@HiltViewModel(assistedFactory = LocationsViewModel.Factory::class) -class LocationsViewModel @AssistedInject constructor( - mediaDistributor: MediaDistributor, - @Assisted("city") private val gpsLocationNameCity: String, - @Assisted("country") private val gpsLocationNameCountry: String -) : ViewModel() { - - @AssistedFactory - interface Factory { - fun create( - @Assisted("city") gpsLocationNameCity: String, - @Assisted("country") gpsLocationNameCountry: String - ): LocationsViewModel - } - - val mediaState by lazy { - mediaDistributor.locationBasedMedia( - gpsLocationNameCity = gpsLocationNameCity, - gpsLocationNameCountry = gpsLocationNameCountry - ).stateIn(viewModelScope, Eagerly, MediaState()) - } - - val latestGeoMedia = mediaDistributor.geoMediaFlow - .map { list -> - list.firstOrNull { it.locationCity == gpsLocationNameCity && it.locationCountry == gpsLocationNameCountry } - } - .stateIn(viewModelScope, Eagerly, null) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt index 6e7c7b0a3a..d8bbcdaa90 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController +import com.dot.gallery.core.Constants import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaSelector @@ -36,9 +37,10 @@ import com.dot.gallery.core.Settings.Misc.rememberForceTheme import com.dot.gallery.core.Settings.Misc.rememberIsDarkMode import com.dot.gallery.core.presentation.components.AppBarContainer import com.dot.gallery.core.presentation.components.NavigationComp +import com.dot.gallery.core.presentation.components.util.permissionGranted import com.dot.gallery.core.util.SetupMediaProviders import com.dot.gallery.feature_node.domain.model.UIEvent -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.UpdateMediaDatabase import com.dot.gallery.feature_node.domain.util.EventHandler import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.toggleOrientation @@ -48,12 +50,12 @@ import dev.chrisbanes.haze.LocalHazeStyle import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -61,7 +63,7 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var eventHandler: EventHandler @Inject - lateinit var repository: MediaRepository + internal lateinit var updateMediaDatabase: UpdateMediaDatabase @Inject lateinit var mediaDistributor: MediaDistributor @Inject @@ -76,6 +78,9 @@ class MainActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) enforceSecureFlag() enableEdgeToEdge() + if (permissionGranted(Constants.PERMISSIONS)) { + mediaDistributor.hasPermission.value = true + } setContent { GalleryTheme { val allowBlur by rememberAllowBlur() @@ -113,7 +118,7 @@ class MainActivity : AppCompatActivity() { when (event) { UIEvent.UpdateDatabase -> { delay(1000L) - repository.updateInternalDatabase() + updateMediaDatabase() } UIEvent.NavigationUpEvent -> eventHandler.navigateUpAction() @@ -191,4 +196,4 @@ class MainActivity : AppCompatActivity() { } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt index 972b2d8cb7..ae4884309b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt @@ -5,6 +5,7 @@ package com.dot.gallery.feature_node.presentation.mediaview +import android.content.pm.ActivityInfo import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Rect @@ -12,6 +13,7 @@ import android.os.Build import android.os.Handler import android.os.HandlerThread import android.view.PixelCopy +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity @@ -57,7 +59,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -76,7 +77,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.dot.gallery.feature_node.presentation.mediaview.components.media.MotionPhotoState +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.composables.core.BottomSheet import com.composables.core.SheetDetent.Companion.FullyExpanded import com.composables.core.rememberBottomSheetState @@ -95,16 +96,15 @@ import com.dot.gallery.core.Settings.Misc.rememberShowMediaViewDateHeader import com.dot.gallery.core.Settings.Misc.rememberVideoAutoplay import com.dot.gallery.core.navigateUp import com.dot.gallery.core.presentation.components.util.swipe +import com.dot.gallery.core.setFollowTheme +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isImage +import com.dot.gallery.feature_node.data.util.isVideo +import com.dot.gallery.feature_node.data.util.readUriOnly import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isImage -import com.dot.gallery.feature_node.domain.util.isVideo -import com.dot.gallery.feature_node.domain.util.readUriOnly import com.dot.gallery.feature_node.presentation.mediaview.MediaViewViewModel.MediaViewEvent import com.dot.gallery.feature_node.presentation.mediaview.components.GroupMemberSelectionBar import com.dot.gallery.feature_node.presentation.mediaview.components.GroupMemberStrip @@ -113,13 +113,8 @@ import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewQ import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewSheetDetails import com.dot.gallery.feature_node.presentation.mediaview.components.media.MediaPreviewComponent import com.dot.gallery.feature_node.presentation.mediaview.components.media.MotionPhotoFilmstrip +import com.dot.gallery.feature_node.presentation.mediaview.components.media.MotionPhotoState import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayerController -import com.dot.gallery.feature_node.presentation.cast.FCastViewModel -import com.dot.gallery.feature_node.presentation.cast.components.CastButton -import com.dot.gallery.feature_node.presentation.cast.components.FCastDevicePickerDialog -import com.dot.gallery.feature_node.presentation.cast.components.CastPermissionsDialog -import com.dot.gallery.feature_node.presentation.cast.components.CastStatusBanner -import com.dot.gallery.feature_node.presentation.util.shareMedia import com.dot.gallery.feature_node.presentation.util.FullBrightnessWindow import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.ProvideInsets @@ -132,6 +127,7 @@ import com.dot.gallery.feature_node.presentation.util.rememberGestureNavigationE import com.dot.gallery.feature_node.presentation.util.rememberNavigationBarHeight import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController import com.dot.gallery.feature_node.presentation.util.setHdrMode +import com.dot.gallery.feature_node.presentation.util.shareMedia import com.dot.gallery.feature_node.presentation.util.toggleSystemBars import com.dot.gallery.ui.theme.isDarkTheme import com.github.panpf.sketch.BitmapImage @@ -140,7 +136,8 @@ import com.github.panpf.sketch.sketch import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials -import androidx.hilt.navigation.compose.hiltViewModel +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -148,8 +145,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.time.Duration.Companion.seconds @Composable fun rememberedDerivedState( @@ -177,15 +172,12 @@ fun MediaViewScreenRoute( toggleRotate: () -> Unit, paddingValues: PaddingValues, isStandalone: Boolean = false, + isSecureReview: Boolean = false, mediaId: Long, target: String? = null, mediaState: State>, metadataState: State, albumsState: State, - vaultState: State, - restoreMedia: ((Vault, T, () -> Unit) -> Unit)? = null, - deleteMedia: ((Vault, T, () -> Unit) -> Unit)? = null, - currentVault: Vault? = null, sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope, ) { @@ -194,15 +186,12 @@ fun MediaViewScreenRoute( toggleRotate = toggleRotate, paddingValues = paddingValues, isStandalone = isStandalone, + isSecureReview = isSecureReview, mediaId = mediaId, target = target, mediaState = mediaState, metadataState = metadataState, albumsState = albumsState, - vaultState = vaultState, - restoreMedia = restoreMedia, - deleteMedia = deleteMedia, - currentVault = currentVault, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope, ensureMetadataAvailable = viewModel::ensureMetadataAvailable, @@ -223,15 +212,12 @@ fun MediaViewScreen( toggleRotate: () -> Unit, paddingValues: PaddingValues, isStandalone: Boolean = false, + isSecureReview: Boolean = false, mediaId: Long, target: String? = null, mediaState: State>, metadataState: State, albumsState: State, - vaultState: State, - restoreMedia: ((Vault, T, () -> Unit) -> Unit)? = null, - deleteMedia: ((Vault, T, () -> Unit) -> Unit)? = null, - currentVault: Vault? = null, sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope, ensureMetadataAvailable: (Media?, MediaMetadataState) -> Unit = { _, _ -> }, @@ -246,16 +232,11 @@ fun MediaViewScreen( var initialPageSetup by rememberSaveable(mediaId) { mutableStateOf(false) } - // FCast - val fcastVm: FCastViewModel = hiltViewModel() - val fcastState by fcastVm.state.collectAsStateWithLifecycle() - var showCastPicker by rememberSaveable { mutableStateOf(false) } - var showCastPermissions by rememberSaveable { mutableStateOf(false) } - // Use pagerMedia for paging (only representatives when grouped, otherwise all media) val pagerItems by rememberedDerivedState(mediaState.value) { val pager = mediaState.value.pagerMedia - if (pager.isNotEmpty()) pager else mediaState.value.media + val items = pager.ifEmpty { mediaState.value.media } + items.distinctBy { it.id } } // Use only primitive ids/sizes as saveable keys (avoid passing full media list object) @@ -368,7 +349,9 @@ fun MediaViewScreen( canAutoPlay ) { currentMedia?.isVideo == true && canAutoPlay } val isReadOnly by rememberedDerivedState { currentMedia?.readUriOnly == true } - val showInfo by rememberedDerivedState { currentMedia?.trashed == 0 && !isReadOnly } + val showInfo by rememberedDerivedState { + currentMedia?.trashed == 0 && !isReadOnly && !isSecureReview + } var showUI by rememberSaveable { mutableStateOf(true) } var isTopDark by remember { mutableStateOf(false) } @@ -386,6 +369,13 @@ fun MediaViewScreen( val activity = LocalActivity.current val window = LocalWindowInfo.current val density = LocalDensity.current + + DisposableEffect(activity) { + onDispose { + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + val halfScreenHeight by remember( window, density @@ -596,6 +586,13 @@ fun MediaViewScreen( uiEvents.collect { event -> when (event) { MediaViewEvent.ScrollToFirstPage -> pagerState.animateScrollToPage(0) + is MediaViewEvent.ShowMessage -> { + Toast.makeText( + context, + event.messageResource, + Toast.LENGTH_SHORT, + ).show() + } } } } @@ -671,12 +668,13 @@ fun MediaViewScreen( mutableStateOf(IntOffset(0, 0)) } val displayMedia = media ?: return@AnimatedVisibility + val sharedElementMedia = pagerMedia ?: displayMedia with(sharedTransitionScope) { MediaPreviewComponent( modifier = Modifier .mediaSharedElement( allowAnimation = canAnimateContent, - media = displayMedia, + media = sharedElementMedia, animatedVisibilityScope = animatedContentScope ), containerModifier = Modifier @@ -708,8 +706,7 @@ fun MediaViewScreen( isPhotosphere = mediaMetadata?.isPhotosphere == true, isMotionPhoto = mediaMetadata?.isMotionPhoto == true, motionPhotoState = motionPhotoState, - currentVault = currentVault, - rotationDisabled = isLocked, + rotationDisabled = isLocked || isSecureReview, onImageRotated = { newRotation -> showRotationHelper.value = media?.isImage == true && newRotation != 0 && newRotation != 360 @@ -734,15 +731,6 @@ fun MediaViewScreen( windowInsetsController.toggleSystemBars(false) } } - // Mute local player while casting to avoid double audio - val isCasting = fcastState.connectedDevice != null - LaunchedEffect(isCasting) { - if (isCasting) { - player.volume = 0f - } else { - player.volume = 1f - } - } val resources = LocalResources.current val width = remember(context) { resources.displayMetrics.widthPixels } @@ -829,20 +817,6 @@ fun MediaViewScreen( buffer = buffer, toggleRotate = toggleRotate, frameRate = frameRate, - onCastSeek = if (fcastState.connectedDevice != null) { - { seconds -> fcastVm.seek(seconds) } - } else null, - onCastPlayPause = if (fcastState.connectedDevice != null) { - { playing -> - if (playing) fcastVm.resume() else fcastVm.pause() - } - } else null, - onCastVolume = if (fcastState.connectedDevice != null) { - { vol -> fcastVm.setVolume(vol) } - } else null, - onCastSpeed = if (fcastState.connectedDevice != null) { - { spd -> fcastVm.setSpeed(spd) } - } else null ) } } @@ -851,14 +825,22 @@ fun MediaViewScreen( } } // Sync status bar icon color with the top image luminance - LaunchedEffect(isTopDark) { - // Dark top → white status icons; bright top → dark status icons - windowInsetsController.isAppearanceLightStatusBars = !isTopDark + val isCurrentVideo by rememberedDerivedState(currentMedia) { + currentMedia?.isVideo == true + } + LaunchedEffect(isTopDark, autoContrast, isDarkTheme, allowBlur, isCurrentVideo) { + val followTheme = if (autoContrast) { + !isTopDark + } else { + !allowBlur && !isCurrentVideo + } + windowInsetsController.isAppearanceLightStatusBars = + if (followTheme) !isDarkTheme else false + eventHandler.setFollowTheme(followTheme) } DisposableEffect(Unit) { - val previousLightStatusBars = windowInsetsController.isAppearanceLightStatusBars onDispose { - windowInsetsController.isAppearanceLightStatusBars = previousLightStatusBars + eventHandler.setFollowTheme(true) } } @@ -904,76 +886,8 @@ fun MediaViewScreen( }, onLock = { isLocked = !isLocked - }, - castButton = if (fcastVm.isCastAvailable()) { { followTheme -> - CastButton( - isConnected = fcastState.connectedDevice != null, - isConnecting = fcastState.isConnecting, - followTheme = followTheme, - onClick = { - if (fcastState.connectedDevice != null) { - showCastPicker = true - } else if (!fcastVm.hasAllPermissions()) { - showCastPermissions = true - } else { - fcastVm.startDiscovery() - showCastPicker = true - } - } - ) - } } else null, - castBanner = if (fcastVm.isCastAvailable() && fcastState.connectedDevice != null) { - { - CastStatusBanner( - deviceName = fcastState.connectedDevice?.name ?: "", - onStop = { fcastVm.stopCasting() }, - onClick = { showCastPicker = true } - ) - } - } else null - ) - - // Auto-cast current media when device connects - LaunchedEffect(fcastState.connectedDevice?.host) { - val device = fcastState.connectedDevice - val media = currentMedia - if (device != null && media != null && fcastState.castingMediaId == null) { - fcastVm.castMedia(media) } - } - - // FCast device picker dialog - if (showCastPicker) { - FCastDevicePickerDialog( - state = fcastState, - onDeviceSelected = { device -> - fcastVm.connect(device) - showCastPicker = false - }, - onCastMedia = { - currentMedia?.let { fcastVm.castMedia(it) } - }, - onStopCasting = { - fcastVm.stopCasting() - }, - onDisconnect = { - fcastVm.disconnect() - showCastPicker = false - }, - onDismiss = { - fcastVm.stopDiscovery() - showCastPicker = false - } - ) - } - - // Cast permissions checklist dialog - if (showCastPermissions) { - CastPermissionsDialog( - permissions = fcastVm.checkPermissions(), - onDismiss = { showCastPermissions = false } - ) - } + ) // Floating filmstrip overlay (positioned like video seekbar) AnimatedVisibility( @@ -1023,7 +937,7 @@ fun MediaViewScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { // Floating action bar for group multi-select - AnimatedVisibility(visible = groupMultiSelectMode) { + AnimatedVisibility(visible = !isSecureReview && groupMultiSelectMode) { GroupMemberSelectionBar( selectedCount = groupMultiSelectedIds.size, totalCount = currentGroupMembers.size, @@ -1055,11 +969,13 @@ fun MediaViewScreen( onSelect = { id -> selectedMemberOverrideId = id }, - multiSelectMode = groupMultiSelectMode, + multiSelectMode = !isSecureReview && groupMultiSelectMode, multiSelectedIds = groupMultiSelectedIds, onEnterMultiSelect = { id -> - groupMultiSelectMode = true - groupMultiSelectedIds = setOf(id) + if (!isSecureReview) { + groupMultiSelectMode = true + groupMultiSelectedIds = setOf(id) + } }, onToggleMultiSelect = { id -> val newSet = if (id in groupMultiSelectedIds) { @@ -1077,7 +993,7 @@ fun MediaViewScreen( } } // Back handler for group multi-select mode - BackHandler(groupMultiSelectMode) { + BackHandler(!isSecureReview && groupMultiSelectMode) { groupMultiSelectMode = false groupMultiSelectedIds = emptySet() } @@ -1114,7 +1030,7 @@ fun MediaViewScreen( label = "MediaViewActions2Alpha" ) AnimatedVisibility( - visible = currentMedia != null, + visible = currentMedia != null && !isSecureReview, enter = enterAnimation, exit = exitAnimation ) { @@ -1171,9 +1087,6 @@ fun MediaViewScreen( currentMedia = currentMedia, showDeleteButton = !isReadOnly, enabled = showUI, - deleteMedia = deleteMedia, - restoreMedia = restoreMedia, - currentVault = currentVault, isImageDark = isBottomDark, autoContrast = autoContrast ) @@ -1181,18 +1094,18 @@ fun MediaViewScreen( } } - MediaViewSheetDetails( - albumsState = albumsState, - vaultState = vaultState, - metadataState = metadataState, - currentMedia = currentMedia, - restoreMedia = restoreMedia, - currentVault = currentVault, - motionPhotoState = motionPhotoState, - ) + if (!isSecureReview) { + MediaViewSheetDetails( + albumsState = albumsState, + metadataState = metadataState, + currentMedia = currentMedia, + isSecureReview = isSecureReview, + motionPhotoState = motionPhotoState, + ) + } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewViewModel.kt index 965bc083fe..4143db0fe1 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewViewModel.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.media.MediaMetadataRetriever import android.net.Uri +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.MediaItem @@ -12,16 +13,21 @@ import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.work.WorkInfo import androidx.work.WorkManager +import com.dot.gallery.R +import com.dot.gallery.core.workers.RotateMediaWorker import com.dot.gallery.core.workers.rotateImage -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.repository.MotionPhotoInfo +import com.dot.gallery.feature_node.data.repository.MotionPhotoRepository +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MotionPhotoHelper -import com.dot.gallery.feature_node.domain.util.MotionPhotoInfo -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isVideo import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.UUID +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -34,9 +40,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.io.File -import java.util.UUID -import javax.inject.Inject private const val NUM_FILMSTRIP_FRAMES = 12 private const val FILMSTRIP_THUMB_HEIGHT = 108 // px, ~36dp @ 3x @@ -45,7 +48,8 @@ private const val FILMSTRIP_THUMB_HEIGHT = 108 // px, ~36dp @ 3x class MediaViewViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val workManager: WorkManager, - private val repository: MediaRepository + private val repository: MediaRepository, + private val motionPhotoRepository: MotionPhotoRepository, ) : ViewModel() { private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) @@ -90,6 +94,11 @@ class MediaViewViewModel @Inject constructor( } if (media.id == currentMotionMediaId) return + // Release any playing motion player before switching to a new media item, + // otherwise the old player + progress loop keeps running and its surface + // overlays the next page's image (black screen bug). + releaseMotionPlayer() + extractionJob?.cancel() val oldFile = _motionPhotoExtraction.value.videoFile @@ -100,10 +109,10 @@ class MediaViewViewModel @Inject constructor( oldFile?.delete() val uri: Uri = media.getUri() - val info = MotionPhotoHelper.parseInfo(context, uri) ?: return@launch + val info = motionPhotoRepository.parseInfo(uri = uri) ?: return@launch _motionPhotoExtraction.value = MotionPhotoExtraction(info = info) - val file = MotionPhotoHelper.extractVideo(context, uri, info) ?: return@launch + val file = motionPhotoRepository.extractVideo(uri = uri, info = info) ?: return@launch val duration = try { MediaMetadataRetriever().use { mmr -> @@ -117,7 +126,10 @@ class MediaViewViewModel @Inject constructor( info = info, videoFile = file, durationMs = duration ) - val frames = MotionPhotoHelper.extractFrames(file, NUM_FILMSTRIP_FRAMES) + val frames = motionPhotoRepository.extractFrames( + file = file, + frameCount = NUM_FILMSTRIP_FRAMES, + ) val composite = stitchFrames(frames, FILMSTRIP_THUMB_HEIGHT) _motionPhotoExtraction.value = MotionPhotoExtraction( info = info, videoFile = file, durationMs = duration, @@ -272,9 +284,21 @@ class MediaViewViewModel @Inject constructor( viewModelScope.launch { workManager.getWorkInfoByIdFlow(id).filterNotNull().collect { info -> if (info.state.isFinished) { - if (info.state == WorkInfo.State.SUCCEEDED) { - delay(300) // wait for media store to be updated - _uiEvents.emit(MediaViewEvent.ScrollToFirstPage) + when (info.state) { + WorkInfo.State.SUCCEEDED -> { + delay(300) // wait for media store to be updated + _uiEvents.emit(MediaViewEvent.ScrollToFirstPage) + } + + WorkInfo.State.FAILED -> { + _uiEvents.emit( + MediaViewEvent.ShowMessage( + messageResource = rotationFailureMessage(workInfo = info), + ), + ) + } + + else -> Unit } rotateWorkId = null } @@ -282,6 +306,21 @@ class MediaViewViewModel @Inject constructor( } } + @StringRes + private fun rotationFailureMessage(workInfo: WorkInfo): Int { + return when (workInfo.outputData.getInt( + RotateMediaWorker.KEY_FAILURE_REASON, + RotateMediaWorker.FAILURE_REASON_GENERIC, + )) { + RotateMediaWorker.FAILURE_REASON_TOO_LARGE -> R.string.rotation_failed_too_large + RotateMediaWorker.FAILURE_REASON_UNSUPPORTED_FORMAT -> { + R.string.rotation_failed_unsupported_format + } + + else -> R.string.rotation_failed + } + } + override fun onCleared() { releaseMotionPlayer() _motionPhotoExtraction.value.videoFile?.delete() @@ -290,5 +329,9 @@ class MediaViewViewModel @Inject constructor( sealed interface MediaViewEvent { data object ScrollToFirstPage : MediaViewEvent + + data class ShowMessage( + @param:StringRes val messageResource: Int, + ) : MediaViewEvent } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/GroupMemberStrip.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/GroupMemberStrip.kt index 51c720efd5..1e51011b17 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/GroupMemberStrip.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/GroupMemberStrip.kt @@ -37,8 +37,8 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,11 +50,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.presentation.components.CheckBox -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.github.panpf.sketch.AsyncImage -import kotlinx.coroutines.launch import kotlin.math.abs +import kotlinx.coroutines.launch private val THUMBNAIL_SIZE = 56.dp private val ITEM_SPACING = 6.dp diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/LocationItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/LocationItem.kt index 9035bbe821..5281978b27 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/LocationItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/LocationItem.kt @@ -1,9 +1,7 @@ package com.dot.gallery.feature_node.presentation.mediaview.components import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,32 +16,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.feature_node.domain.model.LocationData -import com.dot.gallery.feature_node.presentation.util.StaticMapURL -import com.dot.gallery.feature_node.presentation.util.connectivityState import com.dot.gallery.feature_node.presentation.util.launchMap -import kotlinx.coroutines.ExperimentalCoroutinesApi -@Suppress("KotlinConstantConditions") -@OptIn(ExperimentalCoroutinesApi::class, - ExperimentalGlideComposeApi::class -) @Composable fun LocationItem( modifier: Modifier = Modifier, @@ -101,28 +86,7 @@ fun LocationItem( overflow = TextOverflow.Ellipsis ) } - - val connection by connectivityState() - - AnimatedVisibility( - visible = remember(connection) { - BuildConfig.MAPS_ENABLED && connection.isConnected() - } - ) { - GlideImage( - model = StaticMapURL( - latitude = locationData.latitude, - longitude = locationData.longitude, - darkTheme = isSystemInDarkTheme() - ), - contentScale = ContentScale.Crop, - contentDescription = stringResource(R.string.location_map_cd), - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(10.dp)) - ) - } } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt index 9804d9a017..084f41298b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard @@ -41,17 +42,16 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.domain.model.InfoRow -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.feature_node.domain.util.isVideo import com.dot.gallery.feature_node.presentation.util.formatMinSec import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.toBitrateString -import androidx.compose.ui.graphics.vector.ImageVector -import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Locale +import kotlinx.coroutines.launch @Composable fun MediaInfoRow( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewAppBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewAppBar.kt index 5eb985d8c5..a8fd95f3b6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewAppBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewAppBar.kt @@ -51,8 +51,8 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.ui.theme.BlackScrim @@ -83,8 +83,6 @@ fun MediaViewAppBar( onLock: () -> Unit, isImageDark: Boolean = false, autoContrast: Boolean = false, - castButton: @Composable ((followTheme: Boolean) -> Unit)? = null, - castBanner: @Composable (() -> Unit)? = null ) { val allowBlur by rememberAllowBlur() val isDarkTheme = isDarkTheme() @@ -207,8 +205,6 @@ fun MediaViewAppBar( ), verticalAlignment = Alignment.CenterVertically ) { - castButton?.invoke(followTheme) - this@Column.AnimatedVisibility( visible = showInfo, enter = enterAnimation, @@ -227,8 +223,6 @@ fun MediaViewAppBar( } } - castBanner?.invoke() - AnimatedVisibility( visible = isLocked, enter = enterAnimation, @@ -302,4 +296,4 @@ fun MediaViewAppBar( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewQuickBottomBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewQuickBottomBar.kt index 00834d853b..647eaadea8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewQuickBottomBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewQuickBottomBar.kt @@ -19,21 +19,18 @@ import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.Settings.Misc.rememberShowFavoriteButton -import com.dot.gallery.core.util.SdkCompat import com.dot.gallery.core.setFollowTheme -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.canMakeActions -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.domain.util.isTrashed -import com.dot.gallery.feature_node.domain.util.isVideo -import com.dot.gallery.feature_node.domain.util.readUriOnly +import com.dot.gallery.core.util.SdkCompat +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.canMakeActions +import com.dot.gallery.feature_node.data.util.isTrashed +import com.dot.gallery.feature_node.data.util.isVideo +import com.dot.gallery.feature_node.data.util.readUriOnly import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.CopyToClipboardButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.EditButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.FavoriteButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.MediaViewButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.OpenAsButton -import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.RestoreButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.ShareButton import com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons.TrashButton import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState @@ -45,9 +42,6 @@ fun MediaViewQuickBottomBar( currentMedia: T?, showDeleteButton: Boolean, enabled: Boolean, - deleteMedia: ((Vault, T, () -> Unit) -> Unit)?, - restoreMedia: ((Vault, T, () -> Unit) -> Unit)?, - currentVault: Vault?, isImageDark: Boolean = false, autoContrast: Boolean = false ) { @@ -74,92 +68,77 @@ fun MediaViewQuickBottomBar( eventHandler.setFollowTheme(followTheme) } CompositionLocalProvider(LocalContentColor provides contentColor) { - if (currentMedia != null) { - if (currentMedia.isTrashed) { - val scope = rememberCoroutineScope() - val result = rememberActivityResult() - // Restore Component - MediaViewButton( - currentMedia = currentMedia, - imageVector = Icons.Outlined.RestoreFromTrash, - title = stringResource(id = R.string.trash_restore), - followTheme = followTheme, - enabled = enabled - ) { - scope.launch { - handler.trashMedia(result = result, arrayListOf(it), trash = false) + if (currentMedia != null) { + if (currentMedia.isTrashed) { + val scope = rememberCoroutineScope() + val result = rememberActivityResult() + // Restore Component + MediaViewButton( + currentMedia = currentMedia, + imageVector = Icons.Outlined.RestoreFromTrash, + title = stringResource(id = R.string.trash_restore), + followTheme = followTheme, + enabled = enabled + ) { + scope.launch { + handler.trashMedia(result = result, arrayListOf(it), trash = false) + } } - } - // Delete Component - MediaViewButton( - currentMedia = currentMedia, - imageVector = Icons.Outlined.DeleteOutline, - title = stringResource(id = R.string.trash_delete), - enabled = enabled - ) { - scope.launch { - handler.deleteMedia(result = result, arrayListOf(it)) + // Delete Component + MediaViewButton( + currentMedia = currentMedia, + imageVector = Icons.Outlined.DeleteOutline, + title = stringResource(id = R.string.trash_delete), + enabled = enabled + ) { + scope.launch { + handler.deleteMedia(result = result, arrayListOf(it)) + } } - } - } else { - // Share Component - ShareButton( - media = currentMedia, - enabled = enabled, - followTheme = followTheme, - currentVault = currentVault - ) - // Copy to Clipboard - CopyToClipboardButton( - media = currentMedia, - enabled = enabled, - followTheme = followTheme, - currentVault = currentVault - ) - // Favorite Component - val showFavoriteButton by rememberShowFavoriteButton() - if (showFavoriteButton && currentMedia.canMakeActions && SdkCompat.supportsFavorites) { - FavoriteButton( + } else { + // Share Component + ShareButton( media = currentMedia, enabled = enabled, followTheme = followTheme ) - } - if (currentMedia.readUriOnly) { - OpenAsButton( + // Copy to Clipboard + CopyToClipboardButton( media = currentMedia, enabled = enabled, followTheme = followTheme ) - } - // Restore - if (currentMedia.isEncrypted && restoreMedia != null && currentVault != null) { - RestoreButton( - media = currentMedia, - currentVault = currentVault, - restoreMedia = restoreMedia, - followTheme = followTheme - ) - } - // Edit - if (!currentMedia.isEncrypted) { + // Favorite Component + val showFavoriteButton by rememberShowFavoriteButton() + if (showFavoriteButton && currentMedia.canMakeActions && SdkCompat.supportsFavorites) { + FavoriteButton( + media = currentMedia, + enabled = enabled, + followTheme = followTheme + ) + } + if (currentMedia.readUriOnly) { + OpenAsButton( + media = currentMedia, + enabled = enabled, + followTheme = followTheme + ) + } + // Edit EditButton( media = currentMedia, enabled = enabled, followTheme = followTheme ) - } - // Trash Component - if (showDeleteButton) { - TrashButton( - media = currentMedia, - enabled = enabled, - deleteMedia = deleteMedia, - currentVault = currentVault, - followTheme = followTheme - ) + // Trash Component + if (showDeleteButton) { + TrashButton( + media = currentMedia, + enabled = enabled, + followTheme = followTheme + ) + } } } } - } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetActions.kt index 4ed9e3e332..6e46a9fd83 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetActions.kt @@ -1,9 +1,5 @@ package com.dot.gallery.feature_node.presentation.mediaview.components -import android.content.Intent -import android.provider.MediaStore -import android.widget.Toast -import androidx.activity.result.IntentSenderRequest import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -21,14 +17,11 @@ import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -49,188 +42,148 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.dot.gallery.R import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.util.SdkCompat -import com.dot.gallery.feature_node.data.data_source.KeychainHolder +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.canMakeActions +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isImage +import com.dot.gallery.feature_node.data.util.isLocalContent +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.util.canMakeActions -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.domain.util.isImage -import com.dot.gallery.feature_node.domain.util.isLocalContent -import com.dot.gallery.feature_node.domain.util.isVideo import com.dot.gallery.feature_node.presentation.collection.CollectionViewModel import com.dot.gallery.feature_node.presentation.collection.components.AddToCollectionSheet import com.dot.gallery.feature_node.presentation.exif.CopyMediaSheet import com.dot.gallery.feature_node.presentation.exif.MoveMediaSheet import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.copyEncryptedMediaToClipboard import com.dot.gallery.feature_node.presentation.util.copyMediaToClipboard import com.dot.gallery.feature_node.presentation.util.launchEditImageIntent import com.dot.gallery.feature_node.presentation.util.launchEditIntent import com.dot.gallery.feature_node.presentation.util.launchOpenWithIntent import com.dot.gallery.feature_node.presentation.util.launchUseAsIntent -import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.shareEncryptedMedia import com.dot.gallery.feature_node.presentation.util.shareMedia -import com.dot.gallery.feature_node.presentation.vault.VaultViewModel -import com.dot.gallery.feature_node.presentation.vault.components.AddToVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun MediaViewSheetActions( media: T, albumsState: State, - vaults: State, - restoreMedia: ((Vault, T, () -> Unit) -> Unit)?, - currentVault: Vault? ) { val context = LocalContext.current val scope = rememberCoroutineScope() - // Sheet states for complex actions - val hideSheetState = rememberAppBottomSheetState() val copySheetState = rememberAppBottomSheetState() val moveSheetState = rememberAppBottomSheetState() var showCollectionSheet by rememberSaveable { mutableStateOf(false) } val defaultEditor by Settings.Misc.rememberDefaultImageEditor() - // Resolve strings val shareText = stringResource(R.string.share) val copyToClipboardText = stringResource(R.string.copy_to_clipboard) - val hideText = stringResource(R.string.hide) - val restoreText = stringResource(R.string.restore) val openWithText = stringResource(R.string.open_with) val useAsText = stringResource(R.string.use_as) val copyText = stringResource(R.string.copy) val moveText = stringResource(R.string.move) val editText = stringResource(R.string.edit) val addToCollectionText = stringResource(R.string.add_to_collection) - // Lazily create a single KeychainHolder for encrypted operations - val keychainHolder = remember(currentVault) { - if (currentVault != null) lazy { KeychainHolder(context) } else null - } - // Build action list - val actions = remember(media, albumsState.value, vaults.value, currentVault) { - buildList { - // Share - add(ActionGridItem( + val actions = buildList { + add( + ActionGridItem( icon = Icons.Outlined.Share, text = shareText, onClick = { scope.launch { - if (media.isEncrypted && currentVault != null && keychainHolder != null) { - context.shareEncryptedMedia(media, currentVault, keychainHolder.value) - } else { - context.shareMedia(media) - } + context.shareMedia(media) } - } - )) - // Copy to Clipboard - add(ActionGridItem( + }, + ) + ) + add( + ActionGridItem( icon = Icons.Outlined.ContentCopy, text = copyToClipboardText, onClick = { - if (media.isEncrypted && currentVault != null && keychainHolder != null) { - scope.launch { - context.copyEncryptedMediaToClipboard(media, keychainHolder.value) - } - } else { - context.copyMediaToClipboard(media) - } - } - )) - // Hide - if (media.isLocalContent) { - add(ActionGridItem( - icon = Icons.Outlined.Lock, - text = hideText, - enabled = vaults.value.vaults.isNotEmpty(), - onClick = { scope.launch { hideSheetState.show() } } - )) - } - // Restore - if (media.isEncrypted && restoreMedia != null && currentVault != null) { - add(ActionGridItem( - icon = Icons.Outlined.Restore, - text = restoreText, - onClick = { scope.launch { restoreMedia(currentVault, media) {} } } - )) - } - // Open As / Use As - add(ActionGridItem( + context.copyMediaToClipboard(media) + }, + ) + ) + add( + ActionGridItem( icon = Icons.AutoMirrored.Outlined.OpenInNew, text = if (media.isVideo) openWithText else useAsText, onClick = { scope.launch { - if (media.isVideo) context.launchOpenWithIntent(media) - else context.launchUseAsIntent(media) + if (media.isVideo) { + context.launchOpenWithIntent(media) + } else { + context.launchUseAsIntent(media) + } } - } - )) - // Copy & Move - if (albumsState.value.albums.isNotEmpty() && media.canMakeActions) { - add(ActionGridItem( + }, + ) + ) + if (albumsState.value.albums.isNotEmpty() && media.canMakeActions) { + add( + ActionGridItem( icon = Icons.Outlined.CopyAll, text = copyText, - onClick = { scope.launch { copySheetState.show() } } - )) - add(ActionGridItem( + onClick = { scope.launch { copySheetState.show() } }, + ) + ) + add( + ActionGridItem( icon = Icons.AutoMirrored.Outlined.DriveFileMove, text = moveText, - onClick = { scope.launch { moveSheetState.show() } } - )) - } - // Edit - if (!media.isEncrypted) { - add(ActionGridItem( - icon = Icons.Outlined.Edit, - text = editText, - onClick = { - if (media.isImage && defaultEditor != Settings.Misc.EDITOR_BUILTIN) { - try { - context.launchEditImageIntent(defaultEditor, media.getUri()) - } catch (_: Exception) { - context.launchEditIntent(media) - } - } else { + onClick = { scope.launch { moveSheetState.show() } }, + ) + ) + } + add( + ActionGridItem( + icon = Icons.Outlined.Edit, + text = editText, + onClick = { + if (media.isImage && defaultEditor != Settings.Misc.EDITOR_BUILTIN) { + val launched = try { + context.launchEditImageIntent( + packageName = defaultEditor, + uri = media.getUri(), + showError = false, + ) + } catch (_: Exception) { + false + } + if (!launched) { context.launchEditIntent(media) } + } else { + context.launchEditIntent(media) } - )) - } - // Add to Collection - if (media.isLocalContent && media.canMakeActions) { - add(ActionGridItem( + }, + ) + ) + if (media.isLocalContent && media.canMakeActions) { + add( + ActionGridItem( icon = Icons.Outlined.Collections, text = addToCollectionText, - onClick = { showCollectionSheet = true } - )) - } + onClick = { showCollectionSheet = true }, + ) + ) } } - // Render as 2-column grid Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - actions.chunked(2).forEach { rowItems -> + actions.chunked(size = 2).forEach { rowItems -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { rowItems.forEach { item -> ActionGridCell( @@ -238,7 +191,7 @@ fun MediaViewSheetActions( icon = item.icon, text = item.text, enabled = item.enabled, - onClick = item.onClick + onClick = item.onClick, ) } if (rowItems.size < 2) { @@ -248,102 +201,21 @@ fun MediaViewSheetActions( } } - // --- Complex action sheets --- - - // Hide (vault selection) - if (media.isLocalContent) { - val vaultViewModel = hiltViewModel() - var vaultEncryptBehavior by Settings.Vault.rememberVaultEncryptBehavior() - val addToVaultSheetState = rememberAppBottomSheetState() - var selectedVault by remember { mutableStateOf(null) } - val hideResult = rememberActivityResult(onResultOk = { - scope.launch { hideSheetState.hide() } - }) - val hidingText = stringResource(R.string.vault_hide_in_progress) - fun startHide(vault: com.dot.gallery.feature_node.domain.model.Vault, deleteOriginals: Boolean) { - Toast.makeText(context, hidingText, Toast.LENGTH_SHORT).show() - if (deleteOriginals) { - vaultViewModel.hideAndRequestDeletion(vault, media.getUri()) - } else { - vaultViewModel.addMediaKeepOriginals(vault, listOf(media.getUri())) - } - } - SelectVaultSheet( - state = hideSheetState, - vaultState = vaults.value, - onVaultSelected = { vault -> - scope.launch { - when (vaultEncryptBehavior) { - Settings.Vault.ENCRYPT_DELETE -> startHide(vault, deleteOriginals = true) - Settings.Vault.ENCRYPT_KEEP -> startHide(vault, deleteOriginals = false) - else -> { - selectedVault = vault - addToVaultSheetState.show() - } - } - } - } - ) - AddToVaultSheet( - state = addToVaultSheetState, - onEncryptAndDelete = { - val vault = selectedVault ?: return@AddToVaultSheet - startHide(vault, deleteOriginals = true) - }, - onEncryptAndKeep = { - val vault = selectedVault ?: return@AddToVaultSheet - startHide(vault, deleteOriginals = false) - }, - onBehaviorChanged = { vaultEncryptBehavior = it } - ) - LaunchedEffect(Unit) { - vaultViewModel.userMessage.collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - LaunchedEffect(Unit) { - vaultViewModel.pendingDeletions.collect { leftovers -> - if (leftovers.isNotEmpty()) { - if (SdkCompat.supportsMediaStoreRequests) { - val intentSender = MediaStore.createDeleteRequest( - context.contentResolver, - leftovers - ).intentSender - val senderRequest = IntentSenderRequest.Builder(intentSender) - .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) - .build() - hideResult.launch(senderRequest) - } else { - withContext(Dispatchers.IO) { - leftovers.forEach { uri -> - runCatching { - context.contentResolver.delete(uri, null, null) - } - } - } - } - } - } - } - } - - // Copy & Move sheets if (albumsState.value.albums.isNotEmpty() && media.canMakeActions) { CopyMediaSheet( sheetState = copySheetState, mediaList = listOf(media), albumsState = albumsState, - onFinish = { } + onFinish = { }, ) MoveMediaSheet( sheetState = moveSheetState, mediaList = listOf(media), albumState = albumsState, - onFinish = { } + onFinish = { }, ) } - // Add to Collection sheet if (media.isLocalContent && media.canMakeActions) { val collectionViewModel = hiltViewModel() AddToCollectionSheet( @@ -355,7 +227,7 @@ fun MediaViewSheetActions( }, onCreateAndAdd = { name -> collectionViewModel.createCollectionAndAddMedia(name, listOf(media.id)) - } + }, ) } } @@ -364,7 +236,7 @@ private data class ActionGridItem( val icon: ImageVector, val text: String, val enabled: Boolean = true, - val onClick: () -> Unit + val onClick: () -> Unit, ) @OptIn(ExperimentalHazeMaterialsApi::class) @@ -374,7 +246,7 @@ private fun ActionGridCell( icon: ImageVector, text: String, enabled: Boolean = true, - onClick: () -> Unit + onClick: () -> Unit, ) { val isBlurEnabled by rememberAllowBlur() val surfaceColor = MaterialTheme.colorScheme.surfaceContainerHigh @@ -382,12 +254,14 @@ private fun ActionGridCell( if (!isBlurEnabled) { Modifier.background( color = surfaceColor, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) - } else Modifier + } else { + Modifier + } } val hazeStyle = HazeMaterials.regular( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.surface, ) Row( modifier = modifier @@ -395,26 +269,26 @@ private fun ActionGridCell( .then(backgroundModifier) .hazeEffect( state = LocalHazeState.current, - style = hazeStyle + style = hazeStyle, ) .clickable(enabled = enabled, onClick = onClick) .alpha(if (enabled) 1f else 0.4f) .padding(horizontal = 12.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) Text( text = text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetDetails.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetDetails.kt index 26fe66b1ea..a4f05df473 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetDetails.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaViewSheetDetails.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toUpperCase @@ -60,22 +61,19 @@ import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.NavigationBarSpacer +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.canMakeActions +import com.dot.gallery.feature_node.data.util.fileExtension +import com.dot.gallery.feature_node.data.util.getCategory +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isRaw +import com.dot.gallery.feature_node.data.util.isTrashed +import com.dot.gallery.feature_node.data.util.isVideo +import com.dot.gallery.feature_node.data.util.readUriOnly import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState import com.dot.gallery.feature_node.domain.model.rememberLocationData import com.dot.gallery.feature_node.domain.model.rememberMediaDateCaption -import com.dot.gallery.feature_node.domain.util.canMakeActions -import com.dot.gallery.feature_node.domain.util.fileExtension -import com.dot.gallery.feature_node.domain.util.getCategory -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.domain.util.isRaw -import com.dot.gallery.feature_node.domain.util.isTrashed -import com.dot.gallery.feature_node.domain.util.isVideo -import com.dot.gallery.feature_node.domain.util.readUriOnly import com.dot.gallery.feature_node.presentation.exif.MetadataEditSheet import com.dot.gallery.feature_node.presentation.mediaview.components.media.MotionPhotoShotsSection import com.dot.gallery.feature_node.presentation.mediaview.components.media.MotionPhotoState @@ -88,6 +86,7 @@ import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberMediaInfo +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.feature_node.presentation.util.writeRequest import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -98,11 +97,9 @@ import kotlinx.coroutines.launch @Composable fun MediaViewSheetDetails( albumsState: State, - vaultState: State, metadataState: State, currentMedia: T?, - restoreMedia: ((Vault, T, () -> Unit) -> Unit)?, - currentVault: Vault?, + isSecureReview: Boolean = false, motionPhotoState: MotionPhotoState? = null, ) { val metadata by rememberedDerivedState(metadataState.value, currentMedia) { @@ -258,7 +255,7 @@ fun MediaViewSheetDetails( media = currentMedia, exifMetadata = metadata, onLabelClick = { - if (!currentMedia.readUriOnly) { + if (!currentMedia.readUriOnly && !isSecureReview) { scope.launch { metadataSheetState.show() } @@ -271,7 +268,7 @@ fun MediaViewSheetDetails( mutableStateOf(currentMedia.getCategory) } LaunchedEffect(currentMedia, category, handler) { - if (category == null) { + if (!isSecureReview && category == null) { category = handler.getCategoryForMediaId(currentMedia.id) } } @@ -302,7 +299,7 @@ fun MediaViewSheetDetails( modifier = Modifier .fillMaxWidth() .clickable( - enabled = !currentMedia.readUriOnly, + enabled = !currentMedia.readUriOnly && !isSecureReview, indication = null, interactionSource = remember { MutableInteractionSource() @@ -328,13 +325,6 @@ fun MediaViewSheetDetails( contentColor = MaterialTheme.colorScheme.onTertiaryContainer ) } - if (currentMedia.isEncrypted) { - MediaInfoChip( - text = stringResource(R.string.encrypted), - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError - ) - } } LocationItem( iconBackgroundModifier = Modifier @@ -346,7 +336,7 @@ fun MediaViewSheetDetails( locationData = locationData ) AnimatedVisibility( - visible = currentMedia.canMakeActions + visible = currentMedia.canMakeActions && !isSecureReview ) { Row( modifier = Modifier @@ -486,7 +476,9 @@ fun MediaViewSheetDetails( style = iconBackgroundHazeStyle ), trailingContent = { - if (it.trailingIcon != null && currentMedia.canMakeActions) { + if (it.trailingIcon != null && currentMedia.canMakeActions && + !isSecureReview + ) { MediaInfoChip( text = stringResource(R.string.edit), contentColor = MaterialTheme.colorScheme.secondary, @@ -501,10 +493,15 @@ fun MediaViewSheetDetails( ) } }, - onClick = it.onClick + onClick = if (!isSecureReview) it.onClick else null, + onLongClick = if (isSecureReview) { + {} + } else { + null + }, ) } - if (!currentMedia.isEncrypted) { + if (!isSecureReview) { MediaInfoRow( modifier = Modifier .fillMaxWidth() @@ -528,7 +525,7 @@ fun MediaViewSheetDetails( } ) } - if (category != null) { + if (!isSecureReview && category != null) { val mediaCategoryCounter by handler.getClassifiedMediaCountAtCategory( category!! ).collectAsStateWithLifecycle(0) @@ -541,8 +538,9 @@ fun MediaViewSheetDetails( .fillMaxWidth() .padding(horizontal = 16.dp), label = category!!, - content = stringResource( - R.string.s_items, + content = pluralStringResource( + id = R.plurals.item_count, + count = mediaCategoryCounter, mediaCategoryCounter ), iconBackgroundModifier = Modifier @@ -558,7 +556,7 @@ fun MediaViewSheetDetails( exit = exitAnimation ) { GlideImage( - model = mediaCategoryThumbnail!!.uri, + model = mediaCategoryThumbnail!!.toGlideModel(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -581,21 +579,20 @@ fun MediaViewSheetDetails( } } } - item { - MediaViewSheetActions( - media = currentMedia, - albumsState = albumsState, - vaults = vaultState, - restoreMedia = restoreMedia, - currentVault = currentVault - ) + if (!isSecureReview) { + item { + MediaViewSheetActions( + media = currentMedia, + albumsState = albumsState, + ) + } } item { NavigationBarSpacer() } } - if (metadataSheetState.isVisible) { + if (!isSecureReview && metadataSheetState.isVisible) { MetadataEditSheet( state = metadataSheetState, media = currentMedia, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/AddToCollectionButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/AddToCollectionButton.kt index 3a305b92ea..a4c5fae36f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/AddToCollectionButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/AddToCollectionButton.kt @@ -15,8 +15,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.collection.CollectionViewModel import com.dot.gallery.feature_node.presentation.collection.components.AddToCollectionSheet diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyButton.kt index 45f48cd41c..8f7d876c5a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyButton.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.State import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.exif.CopyMediaSheet import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyToClipboardButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyToClipboardButton.kt index 3d024fb846..947c2a393d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyToClipboardButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/CopyToClipboardButton.kt @@ -3,27 +3,19 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.actionbut import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.presentation.util.copyEncryptedMediaToClipboard +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.util.copyMediaToClipboard -import kotlinx.coroutines.launch @Composable fun CopyToClipboardButton( media: T, enabled: Boolean, followTheme: Boolean = false, - currentVault: Vault? = null ) { val context = LocalContext.current - val scope = rememberCoroutineScope() MediaViewButton( currentMedia = media, imageVector = Icons.Outlined.ContentCopy, @@ -31,13 +23,6 @@ fun CopyToClipboardButton( title = stringResource(R.string.copy_to_clipboard), enabled = enabled ) { - if (it.isEncrypted && currentVault != null) { - scope.launch { - val keychainHolder = KeychainHolder(context) - context.copyEncryptedMediaToClipboard(it, keychainHolder) - } - } else { - context.copyMediaToClipboard(it) - } + context.copyMediaToClipboard(it) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/EditButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/EditButton.kt index 9f0343110d..508e94ccc6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/EditButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/EditButton.kt @@ -8,9 +8,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isImage +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isImage import com.dot.gallery.feature_node.presentation.util.launchEditImageIntent import com.dot.gallery.feature_node.presentation.util.launchEditIntent @@ -30,13 +30,20 @@ fun EditButton( enabled = enabled ) { if (it.isImage && defaultEditor != Settings.Misc.EDITOR_BUILTIN) { - try { - context.launchEditImageIntent(defaultEditor, it.getUri()) + val launched = try { + context.launchEditImageIntent( + packageName = defaultEditor, + uri = it.getUri(), + showError = false, + ) } catch (_: Exception) { + false + } + if (!launched) { context.launchEditIntent(it) } } else { context.launchEditIntent(it) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/FavoriteButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/FavoriteButton.kt index dc5fb8bd1e..cb863d3d35 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/FavoriteButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/FavoriteButton.kt @@ -11,9 +11,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.LocalMediaHandler -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.isFavorite -import com.dot.gallery.feature_node.domain.util.readUriOnly +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.isFavorite +import com.dot.gallery.feature_node.data.util.readUriOnly import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/HideButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/HideButton.kt deleted file mode 100644 index a4b59b61ba..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/HideButton.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons - -import android.content.Intent -import android.provider.MediaStore -import android.widget.Toast -import androidx.activity.result.IntentSenderRequest -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.dot.gallery.R -import com.dot.gallery.core.Settings -import com.dot.gallery.core.util.SdkCompat -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.util.rememberActivityResult -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.vault.VaultViewModel -import com.dot.gallery.feature_node.presentation.vault.components.AddToVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -fun HideButton( - media: T, - vaults: VaultState, - enabled: Boolean, - followTheme: Boolean = false -) { - val sheetState = rememberAppBottomSheetState() - val scope = rememberCoroutineScope() - MediaViewButton( - currentMedia = media, - imageVector = Icons.Outlined.Lock, - followTheme = followTheme, - enabled = enabled, - title = stringResource(R.string.hide), - ) { - scope.launch { - sheetState.show() - } - } - val context = LocalContext.current - val result = rememberActivityResult(onResultOk = { - scope.launch { - sheetState.hide() - } - }) - val vaultViewModel = hiltViewModel() - var vaultEncryptBehavior by Settings.Vault.rememberVaultEncryptBehavior() - val addToVaultSheetState = rememberAppBottomSheetState() - var selectedVault by remember { mutableStateOf(null) } - val hidingText = stringResource(R.string.vault_hide_in_progress) - fun startHide(vault: Vault, deleteOriginals: Boolean) { - Toast.makeText(context, hidingText, Toast.LENGTH_SHORT).show() - if (deleteOriginals) { - vaultViewModel.hideAndRequestDeletion(vault, media.getUri()) - } else { - vaultViewModel.addMediaKeepOriginals(vault, listOf(media.getUri())) - } - } - SelectVaultSheet( - state = sheetState, - vaultState = vaults, - onVaultSelected = { vault -> - scope.launch { - when (vaultEncryptBehavior) { - Settings.Vault.ENCRYPT_DELETE -> startHide(vault, deleteOriginals = true) - Settings.Vault.ENCRYPT_KEEP -> startHide(vault, deleteOriginals = false) - else -> { - selectedVault = vault - addToVaultSheetState.show() - } - } - } - } - ) - AddToVaultSheet( - state = addToVaultSheetState, - onEncryptAndDelete = { - val vault = selectedVault ?: return@AddToVaultSheet - startHide(vault, deleteOriginals = true) - }, - onEncryptAndKeep = { - val vault = selectedVault ?: return@AddToVaultSheet - startHide(vault, deleteOriginals = false) - }, - onBehaviorChanged = { vaultEncryptBehavior = it } - ) - - // Show user feedback (Toast) for hide operations outside vault screen - LaunchedEffect(Unit) { - vaultViewModel.userMessage.collect { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } - - // Collect deletion batches emitted by ViewModel - LaunchedEffect(Unit) { - vaultViewModel.pendingDeletions.collect { leftovers -> - if (leftovers.isNotEmpty()) { - if (SdkCompat.supportsMediaStoreRequests) { - val intentSender = MediaStore.createDeleteRequest( - context.contentResolver, - leftovers - ).intentSender - val senderRequest: IntentSenderRequest = - IntentSenderRequest.Builder(intentSender) - .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) - .build() - result.launch(senderRequest) - } else { - // On API 29, delete directly via ContentResolver - withContext(Dispatchers.IO) { - leftovers.forEach { uri -> - runCatching { - context.contentResolver.delete(uri, null, null) - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MediaViewButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MediaViewButton.kt index 593347b7c7..cd0de90bdf 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MediaViewButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MediaViewButton.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MoveButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MoveButton.kt index 3ff5686bd3..87100064c7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MoveButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/MoveButton.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.State import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.exif.MoveMediaSheet import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/OpenAsButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/OpenAsButton.kt index e35d09e810..8da140f7a4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/OpenAsButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/OpenAsButton.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.util.launchOpenWithIntent import com.dot.gallery.feature_node.presentation.util.launchUseAsIntent import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/RestoreButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/RestoreButton.kt deleted file mode 100644 index 82d582ec8a..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/RestoreButton.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.dot.gallery.feature_node.presentation.mediaview.components.actionbuttons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Restore -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import kotlinx.coroutines.launch - -@Composable -fun RestoreButton( - media: T, - currentVault: Vault, - restoreMedia: (Vault, T, () -> Unit) -> Unit, - followTheme: Boolean = false -) { - val scope = rememberCoroutineScope() - MediaViewButton( - currentMedia = media, - imageVector = Icons.Outlined.Restore, - followTheme = followTheme, - title = stringResource(R.string.restore) - ) { - scope.launch { - restoreMedia(currentVault, it) { - - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/ShareButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/ShareButton.kt index 8050dddb3f..f1e84f9ebb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/ShareButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/ShareButton.kt @@ -7,11 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.isEncrypted -import com.dot.gallery.feature_node.presentation.util.shareEncryptedMedia +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.util.shareMedia import kotlinx.coroutines.launch @@ -20,7 +16,6 @@ fun ShareButton( media: T, enabled: Boolean, followTheme: Boolean = false, - currentVault: Vault? = null ) { val scope = rememberCoroutineScope() val context = LocalContext.current @@ -32,18 +27,7 @@ fun ShareButton( enabled = enabled ) { scope.launch { - if (media.isEncrypted && currentVault != null) { - // Share encrypted media by decrypting it first - val keychainHolder = KeychainHolder(context) - context.shareEncryptedMedia( - media = it, - vault = currentVault, - keychainHolder = keychainHolder - ) - } else { - // Share regular media normally - context.shareMedia(media = it) - } + context.shareMedia(media = it) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/TrashButton.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/TrashButton.kt index a9cb78046f..582d57bda6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/TrashButton.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/actionbuttons/TrashButton.kt @@ -14,9 +14,7 @@ import com.dot.gallery.R import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.Settings.Misc.rememberTrashEnabled import com.dot.gallery.core.util.SdkCompat -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.isEncrypted +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialog import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction import com.dot.gallery.feature_node.presentation.util.rememberActivityResult @@ -28,8 +26,6 @@ fun TrashButton( media: T, followTheme: Boolean = false, enabled: Boolean, - deleteMedia: ((Vault, T, () -> Unit) -> Unit)?, - currentVault: Vault? ) { val handler = LocalMediaHandler.current var shouldMoveToTrash by rememberSaveable { mutableStateOf(true) } @@ -37,7 +33,7 @@ fun TrashButton( val scope = rememberCoroutineScope() val trashEnabled = rememberTrashEnabled() val trashEnabledRes = remember(trashEnabled, media) { - if (trashEnabled.value && !media.isEncrypted && SdkCompat.supportsTrash) R.string.trash else R.string.trash_delete + if (trashEnabled.value && SdkCompat.supportsTrash) R.string.trash else R.string.trash_delete } val result = rememberActivityResult { scope.launch { @@ -68,24 +64,16 @@ fun TrashButton( TrashDialog( appBottomSheetState = state, data = listOf(media), - action = if (deleteMedia != null && currentVault != null) { - TrashDialogAction.DELETE - } else if (shouldMoveToTrash) { + action = if (shouldMoveToTrash) { TrashDialogAction.TRASH } else { TrashDialogAction.DELETE } ) { - if (deleteMedia != null && currentVault != null) { - it.forEach { media -> - deleteMedia(currentVault, media) {} - } + if (shouldMoveToTrash && SdkCompat.supportsTrash) { + handler.trashMedia(result, it, true) } else { - if (shouldMoveToTrash && SdkCompat.supportsTrash) { - handler.trashMedia(result, it, true) - } else { - handler.deleteMedia(result, it) - } + handler.deleteMedia(result, it) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt index ef4c942b53..062ba88d9c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt @@ -20,9 +20,8 @@ import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.media3.exoplayer.ExoPlayer -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.isVideo +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.isVideo import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayer import com.dot.gallery.feature_node.presentation.util.LocalHazeState import dev.chrisbanes.haze.hazeSource @@ -45,7 +44,6 @@ fun MediaPreviewComponent( isPhotosphere: Boolean = false, isMotionPhoto: Boolean = false, motionPhotoState: MotionPhotoState? = null, - currentVault: Vault? = null, videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, ) { AnimatedVisibility( @@ -66,6 +64,7 @@ fun MediaPreviewComponent( Box( modifier = Modifier .fillMaxSize() + .then(modifier) .then(containerModifier) .offset { offset }, ) { @@ -76,7 +75,7 @@ fun MediaPreviewComponent( exit = fadeOut() ) { VideoPlayer( - modifier = modifier, + modifier = Modifier, media = media, playWhenReady = playWhenReady, videoController = videoController, @@ -92,7 +91,7 @@ fun MediaPreviewComponent( exit = fadeOut() ) { ZoomablePagerImage( - modifier = modifier, + modifier = Modifier, media = media, uiEnabled = uiEnabled, rotationDisabled = rotationDisabled, @@ -102,7 +101,7 @@ fun MediaPreviewComponent( ) } - if (!media.isVideo && motionPhotoState != null) { + if (!media.isVideo && isMotionPhoto && motionPhotoState != null) { MotionPhotoSurface(state = motionPhotoState) } @@ -115,12 +114,11 @@ fun MediaPreviewComponent( PanoramaImageViewer( media = media, isPhotosphere = isPhotosphere, - modifier = modifier, + modifier = Modifier, onItemClick = onItemClick, - currentVault = currentVault ) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MotionPhotoViewer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MotionPhotoViewer.kt index 4466dea310..3db13034ac 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MotionPhotoViewer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MotionPhotoViewer.kt @@ -62,8 +62,8 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPresentationState import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.MotionPhotoInfo +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MotionPhotoInfo import com.dot.gallery.feature_node.presentation.mediaview.MediaViewViewModel import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.rememberSurfaceCapture diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/PanoramaImageViewer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/PanoramaImageViewer.kt index d030530f1e..8a7f37d583 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/PanoramaImageViewer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/PanoramaImageViewer.kt @@ -5,7 +5,6 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.media -import android.net.Uri import android.view.View import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -29,19 +28,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.net.toFile import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.decoder.EncryptedPanoramaImageLoader -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.rememberSurfaceCapture import com.dot.gallery.libs.panoramaviewer.CameraState -import com.dot.gallery.libs.panoramaviewer.PanoramaImageLoader import com.dot.gallery.libs.panoramaviewer.PanoramaViewer import com.dot.gallery.libs.panoramaviewer.ProjectionType import dev.chrisbanes.haze.hazeSource @@ -53,25 +46,9 @@ fun PanoramaImageViewer( isPhotosphere: Boolean, modifier: Modifier = Modifier, onItemClick: () -> Unit = {}, - currentVault: Vault? = null ) { val projectionType = if (isPhotosphere) ProjectionType.SPHERE else ProjectionType.CYLINDER - val context = LocalContext.current - - // Build an encrypted loader when viewing vault media - val imageLoader: PanoramaImageLoader? = remember(media.id, currentVault) { - if (currentVault != null) { - val keychainHolder = KeychainHolder(context) - val encryptedFile = media.getUri().toFile() - EncryptedPanoramaImageLoader(keychainHolder, encryptedFile) - } else null - } - - val imageUri: Uri = remember(media.id, currentVault) { - if (currentVault != null) Uri.EMPTY else media.getUri() - } - var cameraState by remember { mutableStateOf(CameraState()) } var glViewRef by remember { mutableStateOf(null) } val allowBlur by rememberAllowBlur() @@ -98,10 +75,10 @@ fun PanoramaImageViewer( } PanoramaViewer( - imageUri = imageUri, + imageUri = media.getUri(), projectionType = projectionType, gyroscopeEnabled = isPhotosphere, - imageLoader = imageLoader, + imageLoader = null, onTap = onItemClick, onCameraChanged = { cameraState = it }, onViewCreated = { glViewRef = it }, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt index 593ec07b7a..e90d3bbe76 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt @@ -5,13 +5,19 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.media +import android.graphics.drawable.Drawable import android.os.Build import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -24,36 +30,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi as ExperimentalGlideImageComposeApi import com.bumptech.glide.integration.compose.GlideImage +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.dot.gallery.R import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.Settings -import com.dot.gallery.core.decoder.EncryptedRegionDecoder +import com.dot.gallery.core.decoder.glide.galleryMediaSubsamplingImageGenerators import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus import com.dot.gallery.core.presentation.components.util.swipe -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.asSubsamplingImage -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager -import com.github.panpf.sketch.rememberAsyncImagePainter -import com.github.panpf.sketch.request.ComposableImageRequest +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.github.panpf.zoomimage.GlideZoomAsyncImage -import com.github.panpf.zoomimage.ZoomImage import com.github.panpf.zoomimage.compose.glide.ExperimentalGlideComposeApi import com.github.panpf.zoomimage.rememberGlideZoomState import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@OptIn(com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi::class) +@OptIn(ExperimentalGlideImageComposeApi::class) @Composable fun BlurredMediaBackground( media: T, @@ -73,7 +78,7 @@ fun BlurredMediaBackground( .fillMaxSize() .alpha(blurAlpha) .blur(100.dp), - model = media.getUri(), + model = media.toGlideModel(), contentDescription = null, contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -86,8 +91,9 @@ fun BlurredMediaBackground( } } -@OptIn(ExperimentalGlideComposeApi::class, - com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi::class +@OptIn( + ExperimentalGlideComposeApi::class, + ExperimentalGlideImageComposeApi::class, ) @Stable @Composable @@ -103,106 +109,110 @@ fun BoxScope.ZoomablePagerImage( val feedbackManager = rememberFeedbackManager() var isRotating by rememberSaveable(media) { mutableStateOf(false) } var currentRotation by rememberSaveable(media) { mutableIntStateOf(0) } + var loadFailed by rememberSaveable(media) { mutableStateOf(false) } + var retryAttempt by rememberSaveable(media) { mutableIntStateOf(0) } val rotationAnimation by animateFloatAsState( targetValue = if (isRotating) 90f else 0f, label = "rotationAnimation" ) - val zoomState = rememberGlideZoomState() + val subsamplingImageGenerators = remember { + galleryMediaSubsamplingImageGenerators() + } + val zoomState = rememberGlideZoomState(subsamplingImageGenerators = subsamplingImageGenerators) val scope = rememberCoroutineScope() + val requestListener = remember(media) { + object : RequestListener { + override fun onLoadFailed( + exception: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean, + ): Boolean { + loadFailed = true + return false + } - if (media.isEncrypted) { - val painter = rememberAsyncImagePainter( - request = ComposableImageRequest(media.getUri().toString()) { - crossfade(durationMillis = 200) - setExtra( - key = "mediaKeyPreviewEnc", - value = media.idLessKey, - ) - setExtra("realMimeType", media.mimeType) - }, - contentScale = ContentScale.Fit, - filterQuality = FilterQuality.None, - ) - val context = LocalContext.current - val keychainHolder = remember { - KeychainHolder(context) + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean, + ): Boolean { + loadFailed = false + return false + } } - LaunchedEffect(zoomState.subsampling) { - zoomState.subsampling.setRegionDecoders(listOf(EncryptedRegionDecoder.Factory(keychainHolder))) - zoomState.setSubsamplingImage(media.asSubsamplingImage(context)) - } - ZoomImage( - zoomState = zoomState, - painter = painter, - modifier = Modifier - .fillMaxSize() - .swipe( - onSwipeDown = onSwipeDown - ) - .graphicsLayer { - rotationZ = if (isRotating) rotationAnimation else 0f - }.then(modifier), - onTap = { onItemClick() }, - onLongPress = { - if (!rotationDisabled) { - scope.launch { - isRotating = true - feedbackManager.vibrate() - currentRotation += 90 - onImageRotated(currentRotation) - delay(350) - zoomState.zoomable.rotate(currentRotation) - isRotating = false - } - } - }, - alignment = Alignment.Center, - contentDescription = media.label, - scrollBar = null - ) - } else { - GlideZoomAsyncImage( - zoomState = zoomState, - model = media.getUri(), - modifier = Modifier - .fillMaxSize() - .swipe( - onSwipeDown = onSwipeDown - ) - .graphicsLayer { - rotationZ = if (isRotating) rotationAnimation else 0f - } - .then(modifier), - onTap = { onItemClick() }, - onLongPress = { - if (!rotationDisabled) { - scope.launch { - isRotating = true - feedbackManager.vibrate() - currentRotation += 90 - onImageRotated(currentRotation) - delay(350) - zoomState.zoomable.rotate(currentRotation) - isRotating = false - } - } - }, - alignment = Alignment.Center, - contentDescription = media.label, - requestBuilderTransform = { - var builder = it - .signature(GlideInvalidation.signature(media)) - .thumbnail(it.clone().sizeMultiplier(0.1f)) + } - if (media.label.contains(".gif", ignoreCase = true)) { - builder = builder.decode(GifDrawable::class.java) + GlideZoomAsyncImage( + zoomState = zoomState, + model = media.toGlideModel(), + modifier = Modifier + .fillMaxSize() + .swipe( + onSwipeDown = onSwipeDown + ) + .graphicsLayer { + rotationZ = if (isRotating) rotationAnimation else 0f + } + .then(modifier), + onTap = { onItemClick() }, + onLongPress = { + if (!rotationDisabled) { + scope.launch { + isRotating = true + feedbackManager.vibrate() + currentRotation += 90 + onImageRotated(currentRotation) + delay(350) + zoomState.zoomable.rotate(currentRotation) + isRotating = false } + } + }, + alignment = Alignment.Center, + contentDescription = media.label, + requestBuilderTransform = { + var builder = it + .signature(GlideInvalidation.signature(obj = media, variant = retryAttempt)) + .thumbnail(it.clone().sizeMultiplier(0.1f)) + .addListener(requestListener) + + if (media.label.contains(".gif", ignoreCase = true)) { + builder = builder.decode(GifDrawable::class.java) + } - builder + builder + }, + scrollBar = null + ) + + if (loadFailed) { + ImageLoadFailure( + modifier = Modifier.align(Alignment.Center), + onRetry = { + loadFailed = false + retryAttempt += 1 }, - scrollBar = null ) } } - +@Composable +private fun ImageLoadFailure(modifier: Modifier = Modifier, onRetry: () -> Unit) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.image_load_failed), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoDurationHeader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoDurationHeader.kt index 76180a98d7..bc842c1d18 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoDurationHeader.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoDurationHeader.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.unit.dp import com.dot.gallery.core.presentation.components.util.advancedShadow -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.formatMinSec diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt index b2c5ceca03..e9034dcca0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt @@ -35,11 +35,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onVisibilityChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -55,7 +54,7 @@ import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.Settings.Misc.rememberAudioFocus import com.dot.gallery.core.Settings.Misc.rememberVideoAutoplay import com.dot.gallery.core.presentation.components.util.swipe -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.rememberSurfaceCapture import dev.chrisbanes.haze.hazeSource @@ -242,30 +241,10 @@ fun VideoPlayer( ) } - // Loading & decrypt states - if (!playback.ready && !playback.decryptFailed) { + // Loading state + if (!playback.ready) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() - if (playback.isDecrypting) { - Text( - text = "…", - style = MaterialTheme.typography.labelLarge - ) - } - } - } - - if (playback.decryptFailed) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.decrypt_failed_tap_to_retry), - modifier = Modifier - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { vm.retryDecryption() } - ) - ) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt index dd8a41ed93..566f42abfc 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt @@ -85,10 +85,6 @@ fun VideoPlayerController( buffer: Int, toggleRotate: () -> Unit, frameRate: Float, - onCastSeek: ((Double) -> Unit)? = null, - onCastPlayPause: ((Boolean) -> Unit)? = null, - onCastVolume: ((Double) -> Unit)? = null, - onCastSpeed: ((Double) -> Unit)? = null, ) { val scope = rememberCoroutineScope() @@ -116,13 +112,8 @@ fun VideoPlayerController( var currentVolume by rememberSaveable { mutableFloatStateOf(player.volume) } // Keep player volume in sync when configuration changes / media swaps - val isCasting = onCastVolume != null - LaunchedEffect(LocalConfiguration.current, player.currentMediaItem, isMuted, isCasting) { - if (isCasting) { - player.volume = 0f - } else { - player.volume = if (isMuted) 0f else currentVolume - } + LaunchedEffect(LocalConfiguration.current, player.currentMediaItem, isMuted) { + player.volume = if (isMuted) 0f else currentVolume } // Playback speed / menu @@ -142,7 +133,6 @@ fun VideoPlayerController( } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) - onCastSpeed?.invoke(playbackSpeed.toDouble()) showMenu = false } @@ -197,19 +187,13 @@ fun VideoPlayerController( IconButton( onClick = { - if (onCastVolume != null) { - // When casting, only control remote volume; local stays muted - isMuted = !isMuted - onCastVolume.invoke(if (isMuted) 0.0 else currentVolume.toDouble()) + if (isMuted) { + player.volume = currentVolume + isMuted = false } else { - if (isMuted) { - player.volume = currentVolume - isMuted = false - } else { - currentVolume = player.volume - player.volume = 0f - isMuted = true - } + currentVolume = player.volume + player.volume = 0f + isMuted = true } } ) { @@ -395,7 +379,6 @@ fun VideoPlayerController( val target = sliderValue.toLong().coerceIn(0L, totalTime) player.seekTo(target) currentTime.longValue = target - onCastSeek?.invoke(target.toDouble() / 1000.0) isScrubbing = false sensitivityFactor = 1f if (wasPlayingBeforeScrub) { @@ -434,7 +417,6 @@ fun VideoPlayerController( } else { player.pause() } - onCastPlayPause?.invoke(newState) }, modifier = Modifier .align(Alignment.Center) @@ -457,4 +439,4 @@ fun VideoPlayerController( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerViewModel.kt index 7d63d83a75..69b188c04b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerViewModel.kt @@ -14,28 +14,23 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.SeekParameters -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.util.printDebug -import com.dot.gallery.feature_node.presentation.util.printWarning import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import kotlin.time.Duration.Companion.seconds /** * A ViewModel that owns an ExoPlayer instance keyed by a media.id. @@ -62,8 +57,6 @@ class VideoPlayerViewModel @AssistedInject constructor( } data class PlaybackState( - val isDecrypting: Boolean = false, - val decryptFailed: Boolean = false, val ready: Boolean = false, val durationMs: Long = 0L, val positionMs: Long = 0L, @@ -72,15 +65,11 @@ class VideoPlayerViewModel @AssistedInject constructor( val isPlaying: Boolean = false ) - private val keychainHolder = KeychainHolder(appContext) - - private var decryptedFile: File? = null private var initialSeekApplied = false private var progressJob: Job? = null // Public immutable flow - private val _state = - MutableStateFlow(PlaybackState(isDecrypting = media.isEncrypted)) + private val _state = MutableStateFlow(PlaybackState()) val state: StateFlow = _state // Owned player — exposed as StateFlow so Compose recomposes on player recreation @@ -138,33 +127,8 @@ class VideoPlayerViewModel @AssistedInject constructor( } private fun prepareMedia() { - if (media.isEncrypted) { - decryptAndPrepare() - } else { - setAndPrepare(media.getUri(), media.mimeType) - retrieveFrameRate(encrypted = false) - } - } - - private fun decryptAndPrepare() { - viewModelScope.launch { - _state.update { it.copy(isDecrypting = true, decryptFailed = false) } - decryptedFile = withContext(Dispatchers.IO) { - try { - createDecryptedVideoFile(keychainHolder, media) - } catch (t: Throwable) { - printWarning("Decrypt failed: ${t.message}") - null - } - } - if (decryptedFile == null) { - _state.update { it.copy(isDecrypting = false, decryptFailed = true) } - return@launch - } - _state.update { it.copy(isDecrypting = false, decryptFailed = false) } - setAndPrepare(Uri.fromFile(decryptedFile!!), media.mimeType) - retrieveFrameRate(encrypted = true) - } + setAndPrepare(media.getUri(), media.mimeType) + retrieveFrameRate() } private fun setAndPrepare(uri: Uri, mime: String?) { @@ -223,15 +187,11 @@ class VideoPlayerViewModel @AssistedInject constructor( } } - private fun retrieveFrameRate(encrypted: Boolean) { + private fun retrieveFrameRate() { viewModelScope.launch(Dispatchers.IO) { val fps = try { MediaMetadataRetriever().use { r -> - if (encrypted) { - decryptedFile?.inputStream()?.use { r.setDataSource(it.fd) } - } else { - r.setDataSource(appContext, media.getUri()) - } + r.setDataSource(appContext, media.getUri()) r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) ?.toFloat() ?: 60f @@ -271,11 +231,6 @@ class VideoPlayerViewModel @AssistedInject constructor( player.setAudioAttributes(attrs, /* handleAudioFocus = */ wantsFocus) } - fun retryDecryption() { - if (!_state.value.decryptFailed) return - decryptAndPrepare() - } - @OptIn(UnstableApi::class) fun reattachFromComposition() { if (player.isReleased) { @@ -324,8 +279,6 @@ class VideoPlayerViewModel @AssistedInject constructor( } } catch (_: Throwable) { } - decryptedFile?.delete() - decryptedFile = null super.onCleared() } @@ -333,4 +286,4 @@ class VideoPlayerViewModel @AssistedInject constructor( private const val KEY_POSITION = "positionMs" private const val KEY_PLAYING = "wasPlaying" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/createDecryptedVideoFile.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/createDecryptedVideoFile.kt deleted file mode 100644 index aa0e5e0a81..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/createDecryptedVideoFile.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.dot.gallery.feature_node.presentation.mediaview.components.video - -import androidx.core.net.toFile -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import java.io.File -import java.io.FileOutputStream - -fun createDecryptedVideoFile(keychainHolder: KeychainHolder, decryptedMedia: T): File { - val tempFile = File.createTempFile("${decryptedMedia.id}.temp", null) - val encryptedFile = decryptedMedia.getUri().toFile() - val decrypted = keychainHolder.decryptVaultMedia(encryptedFile) - // Stream decrypted content to temp file (constant memory for VLTv2 via tempFile backing) - decrypted.openStream().use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output) - } - } - return tempFile -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt index 435e00a321..f0ed373af9 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt @@ -10,13 +10,13 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle -import androidx.fragment.app.FragmentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContract import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.core.view.WindowCompat +import androidx.fragment.app.FragmentActivity import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.MediaDistributor @@ -24,7 +24,7 @@ import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaSelector import com.dot.gallery.core.MediaSelectorImpl import com.dot.gallery.core.util.SetupMediaProviders -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.UIEvent import com.dot.gallery.feature_node.domain.util.EventHandler import com.dot.gallery.feature_node.presentation.picker.components.PickerScreen @@ -32,15 +32,15 @@ import com.dot.gallery.ui.theme.GalleryTheme import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import javax.inject.Inject class PickerActivityContract( private val mediaType: String = "*/*", - private val allowMultiple: Boolean = true + private val allowMultiple: Boolean = true, ) : ActivityResultContract>() { override fun createIntent(context: Context, input: Any?): Intent { @@ -81,7 +81,7 @@ class PickerActivity : FragmentActivity() { val mediaSelector: MediaSelector = MediaSelectorImpl() private val exportAsMedia by lazy { - intent.getBooleanExtra(EXPORT_AS_MEDIA, false) + callingPackage == packageName && intent.getBooleanExtra(EXPORT_AS_MEDIA, false) } override fun onCreate(savedInstanceState: Bundle?) { @@ -200,4 +200,4 @@ class PickerActivity : FragmentActivity() { const val EXPORT_AS_MEDIA = "EXPORT_AS_MEDIA" const val MEDIA_LIST = "MEDIA_LIST" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt index 40f6808cd6..0e727190cf 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt @@ -11,21 +11,21 @@ import com.dot.gallery.core.Constants import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.Resource import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.mapMedia import com.dot.gallery.feature_node.presentation.util.mediaFlowWithType import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject @HiltViewModel open class PickerViewModel @Inject constructor( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt index 622962cd63..8d2a8af62d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt @@ -35,11 +35,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.presentation.components.MediaItemHeader -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.presentation.common.components.MediaImage import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.mediaSharedElement @@ -82,6 +82,12 @@ fun PickerMediaScreen( } val selector = LocalMediaSelector.current val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() + val isSelectionActive by selector.isSelectionActive.collectAsStateWithLifecycle() + val selectedIds = selectedMedia.value + val selectionOrderById = remember(selectedIds) { + selectedIds.withIndex().associate { (index, id) -> id to index + 1 } + } + val metadataById = metadataState.value.metadataById LazyVerticalGrid( state = gridState, @@ -154,8 +160,11 @@ fun PickerMediaScreen( MediaImage( modifier = Modifier.animateItem().then(sharedElementModifier), media = item.media, + metadata = metadataById[item.media.id], + selectionActive = isSelectionActive, + isSelected = item.media.id in selectedIds, + selectionNumber = selectionOrderById[item.media.id], canClick = { true }, - metadataState = metadataState, onMediaClick = { feedbackManager.vibrate() val id = it.id @@ -189,4 +198,4 @@ fun PickerMediaScreen( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt index bfee45c2dc..9aa0d7be1c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Visibility -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -84,23 +83,23 @@ import com.dot.gallery.R import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.LocalMediaSelector import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.util.getUri import com.dot.gallery.feature_node.presentation.albums.components.AlbumImage import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreenRoute import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import com.dot.gallery.feature_node.presentation.picker.PickerViewModel +import com.dot.gallery.feature_node.presentation.security.rememberBiometricState import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController import com.dot.gallery.feature_node.presentation.util.selectedMedia import com.dot.gallery.feature_node.presentation.util.toggleOrientation -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState import com.dot.gallery.ui.theme.Dimens import kotlinx.coroutines.launch @@ -427,7 +426,6 @@ fun PickerScreen( ) } val emptyAlbumState = remember { mutableStateOf(AlbumState()) } - val emptyVaultState = remember { mutableStateOf(VaultState(isLoading = false)) } val previousNavigateUp = remember { eventHandler.navigateUpAction } val previousLightStatusBars = remember { windowInsetsController.isAppearanceLightStatusBars } @@ -448,7 +446,6 @@ fun PickerScreen( mediaState = previewMediaState, metadataState = metadataState, albumsState = emptyAlbumState, - vaultState = emptyVaultState, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this@AnimatedContent ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/ImageSearchComponents.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/ImageSearchComponents.kt index 69c751b02b..c063a00a0d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/ImageSearchComponents.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/ImageSearchComponents.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -77,16 +77,17 @@ import com.dot.gallery.R import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.core.presentation.components.MediaItemHeader -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.model.isHeaderKey +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isHeaderKey -import com.dot.gallery.feature_node.domain.util.getUri import com.dot.gallery.feature_node.presentation.albums.components.AlbumImage import com.dot.gallery.feature_node.presentation.common.components.MediaImage import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.Dimens import kotlinx.coroutines.Dispatchers @@ -113,7 +114,7 @@ fun ImageSearchChip( modifier = Modifier .matchParentSize() .clip(RoundedCornerShape(8.dp)), - model = media.getUri(), + model = media.toGlideModel(), contentDescription = stringResource(R.string.image_search_preview), contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -163,7 +164,7 @@ fun ImageSearchPreviewDialog( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)), - model = media.getUri(), + model = media.toGlideModel(), contentDescription = stringResource(R.string.image_search_preview), contentScale = ContentScale.Crop, requestBuilderTransform = { @@ -421,6 +422,7 @@ private fun ImagePickerMediaGrid( val stringToday = stringResource(id = R.string.header_today) val stringYesterday = stringResource(id = R.string.header_yesterday) val gridState = rememberLazyGridState() + val metadataById = metadataState.value.metadataById if (mediaState.media.isEmpty() && !mediaState.isLoading) { Box( @@ -467,7 +469,10 @@ private fun ImagePickerMediaGrid( MediaImage( modifier = Modifier.animateItem(), media = item.media, - metadataState = metadataState, + metadata = metadataById[item.media.id], + selectionActive = false, + isSelected = false, + selectionNumber = null, canClick = { true }, onMediaClick = { onMediaClick(it) }, onItemSelect = { onMediaClick(it) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelper.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelper.kt index 9916ca39d6..2856c52b9e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelper.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelper.kt @@ -1,7 +1,7 @@ package com.dot.gallery.feature_node.presentation.search -import ai.onnxruntime.OrtSession import android.graphics.Bitmap +import com.dot.gallery.core.ml.ManagedOrtSession interface SearchHelper { @@ -10,14 +10,14 @@ interface SearchHelper { fun sortByCosineDistance( searchEmbedding: FloatArray, imageEmbeddingsList: List, - imageIdxList: List + imageIdxList: List, ): List> - suspend fun getTextEmbedding(session: OrtSession, text: String): FloatArray + suspend fun getTextEmbedding(session: ManagedOrtSession, text: String): FloatArray - fun setupTextSession(): OrtSession + fun setupTextSession(): ManagedOrtSession - fun setupVisionSession(): OrtSession + fun setupVisionSession(): ManagedOrtSession - suspend fun getImageEmbedding(session: OrtSession, bitmap: Bitmap): FloatArray -} \ No newline at end of file + suspend fun getImageEmbedding(session: ManagedOrtSession, bitmap: Bitmap): FloatArray +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelperImpl.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelperImpl.kt index 2c049ef97b..6d4844c384 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelperImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchHelperImpl.kt @@ -1,14 +1,14 @@ package com.dot.gallery.feature_node.presentation.search -import ai.onnxruntime.OrtSession import android.graphics.Bitmap +import com.dot.gallery.core.ml.ManagedOrtSession import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.feature_node.presentation.search.helpers.SearchVisionHelper import com.dot.gallery.feature_node.presentation.search.util.dot import com.dot.gallery.feature_node.presentation.util.printDebug +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import javax.inject.Inject class SearchHelperImpl @Inject constructor( private val modelManager: ModelManager, @@ -21,7 +21,7 @@ class SearchHelperImpl @Inject constructor( override fun sortByCosineDistance( searchEmbedding: FloatArray, imageEmbeddingsList: List, - imageIdxList: List + imageIdxList: List, ): List> { val distances = LinkedHashMap() for (i in imageEmbeddingsList.indices) { @@ -29,24 +29,32 @@ class SearchHelperImpl @Inject constructor( distances[imageIdxList[i]] = dist } return distances.toList() - .filter { it.second >= SearchVisionHelper.threshold } - .sortedByDescending { (k, v) -> v } + .filter { it.second >= SearchVisionHelper.THRESHOLD } + .sortedByDescending { it.second } .map { printDebug(it) it } } - override fun setupTextSession(): OrtSession = helper.setupTextSession() + override fun setupTextSession(): ManagedOrtSession { + return helper.setupTextSession() + } - override suspend fun getTextEmbedding(session: OrtSession, text: String): FloatArray = withContext(Dispatchers.IO) { - helper.getTextEmbedding(session, text) + override suspend fun getTextEmbedding(session: ManagedOrtSession, text: String): FloatArray { + return withContext(Dispatchers.IO) { + helper.getTextEmbedding(session = session, text = text) + } } - override fun setupVisionSession(): OrtSession = helper.setupVisionSession() + override fun setupVisionSession(): ManagedOrtSession { + return helper.setupVisionSession() + } - override suspend fun getImageEmbedding(session: OrtSession, bitmap: Bitmap): FloatArray = withContext(Dispatchers.IO) { - helper.getImageEmbedding(session, bitmap) + override suspend fun getImageEmbedding(session: ManagedOrtSession, bitmap: Bitmap): FloatArray { + return withContext(Dispatchers.IO) { + helper.getImageEmbedding(session = session, bitmap = bitmap) + } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchMediaItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchMediaItem.kt index 54447950ad..97af541b7f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchMediaItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchMediaItem.kt @@ -6,7 +6,7 @@ package com.dot.gallery.feature_node.presentation.search import androidx.compose.runtime.Stable -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media /** * A generic item for dynamic search carousels (MIME types, lens models, media modes, etc.). diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchScreen.kt index ce673ec6df..c64cf456e9 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchScreen.kt @@ -26,12 +26,13 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.toMutableStateList import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.ImageSearch @@ -53,11 +54,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -68,8 +72,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation @@ -87,18 +89,19 @@ import com.dot.gallery.core.navigate import com.dot.gallery.core.navigateUp import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.SelectionSheet -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaItem import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.classifier.components.CategoryCarousel -import com.dot.gallery.feature_node.presentation.classifier.components.LocationCarousel import com.dot.gallery.feature_node.presentation.classifier.components.SearchCarousel +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.MosaicMediaGrid import com.dot.gallery.feature_node.presentation.common.components.MosaicPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.SettingsOptionLayout import com.dot.gallery.feature_node.presentation.common.components.TimelineScroller +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.util.LocalHazeState @@ -106,6 +109,7 @@ import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.selectedMedia import dev.chrisbanes.haze.hazeSource import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.withContext @@ -124,7 +128,33 @@ fun SearchScreen( val distributor = LocalMediaDistributor.current val searchResults by viewModel.searchResultsState.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle() + // The field owns its text. Mirroring viewModel.query back into it makes fast typing reorder + // characters and strand the ime composing span, because the collection lags the keystrokes and + // a stale write lands mid-composition. Programmatic rewrites arrive as one-shot events instead. + val queryState = rememberTextFieldState() + LaunchedEffect(queryState) { + viewModel.queryOverrides.collect { override -> + if (override != queryState.text.toString()) { + queryState.setTextAndPlaceCursorAtEnd(override) + } + } + } + LaunchedEffect(queryState) { + snapshotFlow { queryState.text.toString() } + // An override publishes the query before it reaches the field, so the resulting text + // already matches and must not be fed back — that would cancel the search it started. + .filter { it != viewModel.query.value } + .collect { viewModel.setQuery(it, apply = false, fromUser = true) } + } + // Rejecting a lone space through the buffer keeps the ime in sync with what we accepted. + val rejectLoneSpace = remember { + InputTransformation { + if (asCharSequence().toString() == " ") revertAllChanges() + } + } val selectedImageMedia by viewModel.selectedImageMedia.collectAsStateWithLifecycle() + val analysisSettings by viewModel.analysisSettings.collectAsStateWithLifecycle() + val isAiAnalysisEnabled = analysisSettings.analysisEnabled val isModelAvailable by viewModel.isModelAvailable.collectAsStateWithLifecycle() var searchHistory by rememberSearchHistory() @@ -134,7 +164,6 @@ fun SearchScreen( // Categories for the carousel val topCategories by viewModel.topCategories.collectAsStateWithLifecycle() - val topLocations by viewModel.topLocations.collectAsStateWithLifecycle() val topMimeTypes by viewModel.topMimeTypes.collectAsStateWithLifecycle() val topLensModels by viewModel.topLensModels.collectAsStateWithLifecycle() val topMediaModes by viewModel.topMediaModes.collectAsStateWithLifecycle() @@ -146,7 +175,9 @@ fun SearchScreen( emptyList() } else { listOf(SettingsEntity.Header("History")) + - searchHistory.map { entry -> + searchHistory.filter { entry -> + entry.mediaId == null || isAiAnalysisEnabled + }.map { entry -> if (entry.mediaId != null) { SettingsEntity.Preference( icon = if (entry.mediaUri == null) Icons.Outlined.ImageSearch else null, @@ -201,7 +232,7 @@ fun SearchScreen( shape = CircleShape ), onClick = { - if (query.isNotEmpty() || selectedImageMedia != null) { + if (queryState.text.isNotEmpty() || selectedImageMedia != null) { viewModel.clearQuery() } else { eventHandler.navigateUp() @@ -218,12 +249,8 @@ fun SearchScreen( OutlinedTextField( modifier = Modifier .fillMaxWidth(), - value = query, - onValueChange = { newQuery -> - if (newQuery != " ") { - viewModel.setQuery(newQuery, apply = false) - } - }, + state = queryState, + inputTransformation = rejectLoneSpace, shape = CircleShape, colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = outlineColor, @@ -234,24 +261,11 @@ fun SearchScreen( keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search ), - keyboardActions = KeyboardActions( - onSearch = { - viewModel.setQuery(query, apply = true) - viewModel.addHistory(query) - }, - onDone = { - viewModel.setQuery(query, apply = true) - viewModel.addHistory(query) - }, - onGo = { - viewModel.setQuery(query, apply = true) - viewModel.addHistory(query) - }, - onSend = { - viewModel.setQuery(query, apply = true) - viewModel.addHistory(query) - } - ), + onKeyboardAction = { + val submitted = queryState.text.toString() + viewModel.setQuery(submitted, apply = true, fromUser = true) + viewModel.addHistory(submitted) + }, leadingIcon = selectedImageMedia?.let { media -> { ImageSearchChip( @@ -271,13 +285,13 @@ fun SearchScreen( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) ) }, - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, trailingIcon = { Row( verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility( - visible = query.isNotBlank() && !searchResults.isSearching && !searchResults.hasSearched, + visible = queryState.text.isNotBlank() && !searchResults.isSearching && !searchResults.hasSearched, enter = fadeIn() + slideInHorizontally { it }, exit = fadeOut() + slideOutHorizontally { it } ) { @@ -289,8 +303,13 @@ fun SearchScreen( shape = CircleShape ), onClick = { - viewModel.setQuery(query, apply = true) - viewModel.addHistory(query) + val submitted = queryState.text.toString() + viewModel.setQuery( + query = submitted, + apply = true, + fromUser = true + ) + viewModel.addHistory(submitted) } ) { Icon( @@ -301,7 +320,10 @@ fun SearchScreen( } } AnimatedVisibility( - visible = isModelAvailable && selectedImageMedia == null && !searchResults.isSearching, + visible = isModelAvailable && + isAiAnalysisEnabled && + selectedImageMedia == null && + !searchResults.isSearching, enter = fadeIn() + slideInHorizontally { it }, exit = fadeOut() + slideOutHorizontally { it } ) { @@ -427,7 +449,11 @@ fun SearchScreen( ) // Category Carousel - if (topCategories.isNotEmpty()) { + if ( + analysisSettings.analysisEnabled && + analysisSettings.categoryClassificationEnabled && + topCategories.isNotEmpty() + ) { SettingsOptionLayout( modifier = Modifier.padding(top = 12.dp), optionList = listOf(SettingsEntity.Header(resources.getString(R.string.browse_categories))), @@ -448,32 +474,6 @@ fun SearchScreen( } } - // Location Carousel - if (topLocations.isNotEmpty()) { - SettingsOptionLayout( - modifier = Modifier.padding(top = 12.dp), - optionList = listOf(SettingsEntity.Header(resources.getString(R.string.locations))), - slimLayout = true - ) - item { - LocationCarousel( - locations = topLocations, - onLocationClick = { locationMedia -> - val city = locationMedia.location.substringBefore(",") - val country = - locationMedia.location.substringAfterLast(", ") - eventHandler.navigate( - Screen.LocationTimelineScreen.location( - gpsLocationNameCity = city, - gpsLocationNameCountry = country - ) - ) - }, - title = null - ) - } - } - // Media Types Carousel if (topMimeTypes.isNotEmpty()) { SettingsOptionLayout( @@ -776,7 +776,7 @@ fun SearchScreen( } // Image search picker bottom sheet - if (showPickerSheet) { + if (showPickerSheet && isAiAnalysisEnabled) { ImageSearchPickerSheet( onMediaSelected = { media -> showPickerSheet = false @@ -787,7 +787,7 @@ fun SearchScreen( } // Image search preview dialog - if (showPreviewDialog && selectedImageMedia != null) { + if (showPreviewDialog && isAiAnalysisEnabled && selectedImageMedia != null) { ImageSearchPreviewDialog( media = selectedImageMedia!!, onDismiss = { showPreviewDialog = false }, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchSuggestion.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchSuggestion.kt index 2bcddf1f14..eaf39ba544 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchSuggestion.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchSuggestion.kt @@ -25,7 +25,7 @@ data class SearchSuggestion( /** * Provides a group of search suggestions with a header title. * Implement this interface to add new suggestion categories - * (e.g. locations, mime types, media tags, albums, etc.) + * (e.g. mime types, media tags, albums, etc.) */ interface SearchSuggestionProvider { /** Header title shown above this group of suggestions */ diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModel.kt index 1c4694ce1f..b373f1df46 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModel.kt @@ -1,7 +1,8 @@ package com.dot.gallery.feature_node.presentation.search import android.content.Context -import android.graphics.BitmapFactory +import android.graphics.Bitmap +import android.util.Log import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,43 +11,54 @@ import androidx.work.WorkManager import com.dot.gallery.R import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.Settings +import com.dot.gallery.core.ml.ModelInferenceException import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.core.ml.ModelStatus -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadata +import com.dot.gallery.core.sandbox.MediaPreviewDecoder +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.MediaGroupType +import com.dot.gallery.feature_node.data.util.classifyGroupType +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.groupKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaGroupType -import com.dot.gallery.feature_node.domain.util.classifyGroupType -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.groupKey +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSettings import com.dot.gallery.feature_node.presentation.library.CategoryMedia -import com.dot.gallery.feature_node.presentation.search.util.centerCrop import com.dot.gallery.feature_node.presentation.util.mapMediaToItem +import com.dot.gallery.injection.qualifier.IoDispatcher import com.frosch2010.fuzzywuzzy_kotlin.FuzzySearch import com.frosch2010.fuzzywuzzy_kotlin.ToStringFunction import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @Stable data class SearchResultsState( @@ -58,69 +70,123 @@ data class SearchResultsState( ) @HiltViewModel -class SearchViewModel @Inject constructor( +class SearchViewModel @Inject internal constructor( mediaDistributor: MediaDistributor, workManager: WorkManager, private val searchHelper: SearchHelper, - repository: MediaRepository, + private val repository: MediaRepository, modelManager: ModelManager, + private val aiMediaAnalysis: AiMediaAnalysis, + private val previewDecoder: MediaPreviewDecoder, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, @param:ApplicationContext - private val context: Context + private val context: Context, ) : ViewModel() { - val isModelAvailable: StateFlow = modelManager.status - .map { it == ModelStatus.READY } + internal val analysisSettings: StateFlow = aiMediaAnalysis.settings .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = modelManager.isReady + started = SharingStarted.Eagerly, + initialValue = AiMediaAnalysisSettings( + analysisEnabled = false, + categoryClassificationEnabled = false, + ), ) - private val imageRecords = mediaDistributor.imageEmbeddingsFlow + val isModelAvailable: StateFlow = modelManager.status + .map { it == ModelStatus.READY } .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() + started = SharingStarted.WhileSubscribed(5000), + initialValue = modelManager.isReady ) private var _query = MutableStateFlow("") val query = _query.asStateFlow() + private val _queryOverrides = MutableSharedFlow( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** + * Query text the view model wants the search field to display, delivered as one-shot events. + * + * The field owns its own text, so [query] must never be mirrored back into it: the mirror lags + * by however long the collection takes, and a stale write landing mid-keystroke desynchronizes + * the ime composing region — characters reorder and the composing span strands. Only genuine + * rewrites (clearing, chip taps, history taps) are published here. + */ + val queryOverrides: SharedFlow = _queryOverrides.asSharedFlow() + + /** + * @param fromUser true when [query] is the text the field already displays, in which case no + * override is emitted. + */ + private fun publishQuery(query: String, fromUser: Boolean = false) { + _query.value = query + if (!fromUser) { + _queryOverrides.tryEmit(query) + } + } + private val _selectedImageMedia = MutableStateFlow(null) val selectedImageMedia = _selectedImageMedia.asStateFlow() private val _searchResultsState = MutableStateFlow(SearchResultsState()) val searchResultsState = _searchResultsState.asStateFlow() + private var searchJob: Job? = null + + init { + viewModelScope.launch { + analysisSettings + .map { settings -> settings.analysisEnabled } + .distinctUntilChanged() + .collect { analysisEnabled -> + val hasAiSearchState = _selectedImageMedia.value != null || + _searchResultsState.value.isSearching || + _searchResultsState.value.isRelevanceSearch + if (!analysisEnabled) { + searchJob?.cancel() + _selectedImageMedia.value = null + if (hasAiSearchState) { + _searchResultsState.value = SearchResultsState() + } + } + } + } + } + private val dateFormats = mediaDistributor.dateFormatsFlow // Top categories for the search screen carousel (matching LibraryScreen style) val topCategories: StateFlow> = combine( repository.getTopCategories(8), - mediaDistributor.timelineMediaFlow - ) { categories, mediaState -> - val mediaMap = mediaState.media.associateBy { it.id } - categories.map { category -> - CategoryMedia( - category = category, - thumbnailMedia = category.thumbnailMediaId?.let { mediaMap[it] } - ) - }.toImmutableList() + mediaDistributor.timelineMediaFlow, + analysisSettings, + ) { categories, mediaState, settings -> + when { + !settings.analysisEnabled || !settings.categoryClassificationEnabled -> { + persistentListOf() + } + + else -> { + val mediaMap = mediaState.media.associateBy { media -> media.id } + categories.map { category -> + CategoryMedia( + category = category, + thumbnailMedia = category.thumbnailMediaId?.let { mediaMap[it] }, + ) + }.toImmutableList() + } + } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = persistentListOf() ) - // Top locations for the search screen carousel (matching LibraryScreen style) - val topLocations: StateFlow> = mediaDistributor.locationsMediaFlow - .map { it.take(10).toImmutableList() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = persistentListOf() - ) - // Top MIME types carousel – grouped by mimeType, readable labels val topMimeTypes: StateFlow> = mediaDistributor.timelineMediaFlow .map { mediaState -> @@ -241,7 +307,7 @@ class SearchViewModel @Inject constructor( } }.toImmutableList() } - .flowOn(Dispatchers.IO) + .flowOn(ioDispatcher) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -279,8 +345,6 @@ class SearchViewModel @Inject constructor( ) }.stateIn(viewModelScope, SharingStarted.Eagerly, SearchIndexerState()) - private var searchJob: Job? = null - fun addHistory(query: String) { viewModelScope.launch { Settings.Search.addHistory(context, query) @@ -313,19 +377,25 @@ class SearchViewModel @Inject constructor( fun clearQuery() { viewModelScope.launch { searchJob?.cancel() - _query.tryEmit("") + publishQuery("") _selectedImageMedia.tryEmit(null) _searchResultsState.tryEmit(SearchResultsState()) } } fun setSelectedMedia(media: Media.UriMedia) { + if (!analysisSettings.value.analysisEnabled) { + return + } _selectedImageMedia.value = media addImageHistory(media) searchByImage(media) } fun restoreImageSearch(mediaId: Long) { + if (!analysisSettings.value.analysisEnabled) { + return + } val media = allMedia.value.media.find { it.id == mediaId } ?: return _selectedImageMedia.value = media searchByImage(media) @@ -337,9 +407,12 @@ class SearchViewModel @Inject constructor( } fun searchByImage(media: Media.UriMedia) { + if (!analysisSettings.value.analysisEnabled) { + return + } searchJob?.cancel() - _query.value = "" - searchJob = viewModelScope.launch(Dispatchers.IO) { + publishQuery("") + searchJob = viewModelScope.launch(ioDispatcher) { _searchResultsState.tryEmit( SearchResultsState( hasSearched = true, @@ -349,65 +422,97 @@ class SearchViewModel @Inject constructor( ) ) try { - val uri = media.getUri() - val contentResolver = context.contentResolver - val bitmap = contentResolver.openInputStream(uri)?.use { inputStream -> - BitmapFactory.decodeStream(inputStream) - } ?: run { + if (!searchHelper.isAvailable) { _searchResultsState.tryEmit( SearchResultsState( hasSearched = true, isSearching = false, progress = 1f, - results = MediaState(error = "Could not load image", isLoading = false) - ) + results = MediaState( + error = context.getString(R.string.ai_models_unavailable), + isLoading = false, + ), + ), ) return@launch } - - if (!searchHelper.isAvailable) { + val bitmap = previewDecoder.decode( + uri = media.getUri(), + mimeType = media.mimeType, + isVideo = media.mimeType.startsWith(prefix = "video/"), + ) ?: run { _searchResultsState.tryEmit( SearchResultsState( hasSearched = true, isSearching = false, progress = 1f, - results = MediaState(error = context.getString(R.string.ai_models_not_installed), isLoading = false) + results = MediaState( + error = context.getString(R.string.could_not_load_image), + isLoading = false, + ), ) ) return@launch } - val croppedBitmap = centerCrop(bitmap, 224) - searchHelper.setupVisionSession().use { session -> - val imageEmbedding = searchHelper.getImageEmbedding(session, croppedBitmap) - val searchResultsPair = searchHelper.sortByCosineDistance( - searchEmbedding = imageEmbedding, - imageEmbeddingsList = imageRecords.value.map { it.embedding }, - imageIdxList = imageRecords.value.map { it.id } - ) - val allMediaList = allMedia.value.media - val results = searchResultsPair.mapNotNull { (id, score) -> - if (id == media.id) return@mapNotNull null - val m = allMediaList.find { it.id == id } - if (m != null) score to m else null + + bitmap.useForSearch { + if (!analysisSettings.value.analysisEnabled) { + return@useForSearch } - val mediaState = mapMediaToItem( - data = results.map { it.second }, - error = "", - albumId = -1L, - defaultDateFormat = dateFormats.value.first, - extendedDateFormat = dateFormats.value.second, - weeklyDateFormat = dateFormats.value.third - ) - _searchResultsState.tryEmit( - SearchResultsState( - hasSearched = true, - isSearching = false, - isRelevanceSearch = true, - progress = 1f, - results = mediaState + searchHelper.setupVisionSession().use { session -> + val imageEmbedding = searchHelper.getImageEmbedding( + session = session, + bitmap = bitmap, ) - ) + currentCoroutineContext().ensureActive() + val searchResultsPair = scoreImageEmbeddings( + searchEmbedding = imageEmbedding, + ) + val allMediaList = allMedia.value.media + val mediaById = allMediaList.associateBy { item -> item.id } + val results = searchResultsPair.mapNotNull { (id, score) -> + if (id == media.id) return@mapNotNull null + val resultMedia = mediaById[id] + if (resultMedia != null) score to resultMedia else null + } + val mediaState = mapMediaToItem( + data = results.map { it.second }, + error = "", + albumId = -1L, + defaultDateFormat = dateFormats.value.first, + extendedDateFormat = dateFormats.value.second, + weeklyDateFormat = dateFormats.value.third, + ) + currentCoroutineContext().ensureActive() + if (!analysisSettings.value.analysisEnabled) { + return@useForSearch + } + _searchResultsState.tryEmit( + SearchResultsState( + hasSearched = true, + isSearching = false, + isRelevanceSearch = true, + progress = 1f, + results = mediaState, + ), + ) + } } + } catch (exception: CancellationException) { + throw exception + } catch (exception: ModelInferenceException) { + Log.w(TAG, "Image search inference failed", exception) + _searchResultsState.tryEmit( + SearchResultsState( + hasSearched = true, + isSearching = false, + progress = 1f, + results = MediaState( + error = context.getString(R.string.ai_model_inference_failed), + isLoading = false, + ), + ) + ) } catch (e: Exception) { _searchResultsState.tryEmit( SearchResultsState( @@ -422,7 +527,7 @@ class SearchViewModel @Inject constructor( } private fun updateQueriedMedia(newMediaState: MediaState) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val query = _query.value if (query.isEmpty()) return@launch val resultsState = _searchResultsState.value @@ -450,9 +555,9 @@ class SearchViewModel @Inject constructor( fun setMimeTypeQuery(mimeType: String, hideExplicitQuery: Boolean = false) { if (hideExplicitQuery) { - _query.value = if (mimeType.startsWith("image")) "Images" else "Videos" + publishQuery(if (mimeType.startsWith("image")) "Images" else "Videos") } else { - _query.value = mimeType + publishQuery(mimeType) } val searchQuery = if (mimeType.contains("/*")) { mimeType.substringBefore("/*") @@ -460,7 +565,7 @@ class SearchViewModel @Inject constructor( mimeType } searchJob?.cancel() - searchJob = viewModelScope.launch(Dispatchers.IO) { + searchJob = viewModelScope.launch(ioDispatcher) { val allMedia = allMedia.value.media val filteredMedia = allMedia.filter { it.mimeType.startsWith(searchQuery) } val mediaState = mapMediaToItem( @@ -488,9 +593,9 @@ class SearchViewModel @Inject constructor( */ fun setMediaModeQuery(modeKey: String) { val spec = mediaModeSpecs.find { it.key == modeKey } ?: return - _query.value = context.getString(spec.labelResId) + publishQuery(context.getString(spec.labelResId)) searchJob?.cancel() - searchJob = viewModelScope.launch(Dispatchers.IO) { + searchJob = viewModelScope.launch(ioDispatcher) { val allMediaMap = allMedia.value.media.associateBy { it.id } val matchingIds = metadata.value.metadata .filter(spec.predicate) @@ -521,9 +626,9 @@ class SearchViewModel @Inject constructor( * Filters metadata by manufacturer + model match and returns associated media. */ fun setLensModelQuery(lensModel: String) { - _query.value = lensModel + publishQuery(lensModel) searchJob?.cancel() - searchJob = viewModelScope.launch(Dispatchers.IO) { + searchJob = viewModelScope.launch(ioDispatcher) { val allMediaMap = allMedia.value.media.associateBy { it.id } val matchingIds = metadata.value.metadata .filter { @@ -559,9 +664,9 @@ class SearchViewModel @Inject constructor( */ fun setGroupTypeQuery(groupTypeKey: String) { val spec = groupTypeSpecs.find { it.key == groupTypeKey } ?: return - _query.value = context.getString(spec.labelResId) + publishQuery(context.getString(spec.labelResId)) searchJob?.cancel() - searchJob = viewModelScope.launch(Dispatchers.IO) { + searchJob = viewModelScope.launch(ioDispatcher) { val groups = allMedia.value.media .groupBy { it.groupKey } .values @@ -589,10 +694,17 @@ class SearchViewModel @Inject constructor( } } - fun setQuery(query: String, apply: Boolean = true) { + /** + * @param fromUser true when [query] is the text the search field already displays, so it does + * not need to be told about it again. See [queryOverrides]. + */ + fun setQuery(query: String, apply: Boolean = true, fromUser: Boolean = false) { + // Publish synchronously, before the cancel: emitting from inside the job means a keystroke + // that cancels its predecessor also drops that predecessor's query, so the field and the + // keyboard action handlers can end up acting on a stale value. + publishQuery(query, fromUser) searchJob?.cancel() - searchJob = viewModelScope.launch(Dispatchers.IO) { - _query.tryEmit(query) + searchJob = viewModelScope.launch(ioDispatcher) { if (query.isEmpty() || !apply) { _searchResultsState.tryEmit(SearchResultsState()) return@launch @@ -607,6 +719,8 @@ class SearchViewModel @Inject constructor( ) ) val allMedia = allMedia.value.media + var modelInferenceFailed = false + var semanticResultsIncluded = false if (query.matches(Regex("^[a-zA-Z0-9!#$&^_.+-]+/[a-zA-Z0-9!#$&-^_.+*]*$"))) { setMimeTypeQuery(query) @@ -637,69 +751,66 @@ class SearchViewModel @Inject constructor( ) return@launch } - val metadataMatches = metadata.value.metadata.filter { mtd -> - mtd.toString().contains(query, ignoreCase = true) - } - if (metadataMatches.isNotEmpty()) { - // If the query matches metadata, filter by that metadata - val filteredMedia = allMedia.filter { media -> - metadataMatches.any { it.mediaId == media.id } - } + val metadataMatchIds = metadata.value.metadata + .filter { it.searchableText.contains(other = query, ignoreCase = true) } + .mapTo(HashSet()) { it.mediaId } + if (metadataMatchIds.isNotEmpty()) { + val filteredMedia = allMedia.filter { it.id in metadataMatchIds } results.mergeWithHighestScore( filteredMedia.map { 1f to it } ) - val mediaState = mapMediaToItem( - data = results.map { it.second }, - error = "", - albumId = -1L, - defaultDateFormat = dateFormats.value.first, - extendedDateFormat = dateFormats.value.second, - weeklyDateFormat = dateFormats.value.third - ) - _searchResultsState.tryEmit( - SearchResultsState( - hasSearched = true, - isSearching = false, - progress = 1f, - results = mediaState - ) - ) - return@launch } - if (searchHelper.isAvailable) { - searchHelper.setupTextSession().use { session -> - val textEmbedding = searchHelper.getTextEmbedding(session, query) - val searchResultsPair = searchHelper.sortByCosineDistance( - searchEmbedding = textEmbedding, - imageEmbeddingsList = imageRecords.value.map { it.embedding }, - imageIdxList = imageRecords.value.map { it.id } - ) - val searchResultsMedia = searchResultsPair.mapNotNull { (id, score) -> - val media = allMedia.find { it.id == id } - if (media != null) score to media else null - } - - results.mergeWithHighestScore(searchResultsMedia) - _searchResultsState.tryEmit( - SearchResultsState( - hasSearched = true, - isSearching = false, - isRelevanceSearch = true, - progress = 0.5f, - results = mapMediaToItem( - data = results.map { it.second }, - error = "", - albumId = -1L, - defaultDateFormat = dateFormats.value.first, - extendedDateFormat = dateFormats.value.second, - weeklyDateFormat = dateFormats.value.third - ) + if (searchHelper.isAvailable && analysisSettings.value.analysisEnabled) { + try { + searchHelper.setupTextSession().use { session -> + val textEmbedding = searchHelper.getTextEmbedding(session, query) + currentCoroutineContext().ensureActive() + if (!analysisSettings.value.analysisEnabled) { + return@launch + } + val searchResultsPair = scoreImageEmbeddings( + searchEmbedding = textEmbedding, ) - ) + val mediaById = allMedia.associateBy { media -> media.id } + val searchResultsMedia = searchResultsPair.mapNotNull { (id, score) -> + val media = mediaById[id] + if (media != null) score to media else null + } + + results.mergeWithHighestScore(searchResultsMedia) + semanticResultsIncluded = true + currentCoroutineContext().ensureActive() + if (!analysisSettings.value.analysisEnabled) { + return@launch + } + _searchResultsState.tryEmit( + SearchResultsState( + hasSearched = true, + isSearching = false, + isRelevanceSearch = true, + progress = 0.5f, + results = mapMediaToItem( + data = results.map { it.second }, + error = "", + albumId = -1L, + defaultDateFormat = dateFormats.value.first, + extendedDateFormat = dateFormats.value.second, + weeklyDateFormat = dateFormats.value.third, + ), + ), + ) + } + } catch (exception: ModelInferenceException) { + modelInferenceFailed = true + Log.w(TAG, "Text search inference failed", exception) } } val fuzzySearchResults = allMedia.parseFuzzySearch(query) results.mergeWithHighestScore(fuzzySearchResults) + currentCoroutineContext().ensureActive() + if (semanticResultsIncluded && !analysisSettings.value.analysisEnabled) { + return@launch + } _searchResultsState.tryEmit( SearchResultsState( hasSearched = true, @@ -722,7 +833,14 @@ class SearchViewModel @Inject constructor( hasSearched = true, isSearching = false, progress = 1f, - results = MediaState(error = "No results found", isLoading = false) + results = MediaState( + error = if (modelInferenceFailed) { + context.getString(R.string.ai_model_inference_failed) + } else { + "No results found" + }, + isLoading = false, + ), ) ) } @@ -739,8 +857,35 @@ class SearchViewModel @Inject constructor( addAll(merged) } + private suspend fun scoreImageEmbeddings( + searchEmbedding: FloatArray, + ): List> { + val results = mutableListOf>() + var afterId = Long.MIN_VALUE + while (true) { + currentCoroutineContext().ensureActive() + if (!analysisSettings.value.analysisEnabled) { + return emptyList() + } + val page = repository.getImageEmbeddingPage( + afterId = afterId, + limit = EMBEDDING_PAGE_SIZE, + ) + if (page.isEmpty()) { + break + } + results += searchHelper.sortByCosineDistance( + searchEmbedding = searchEmbedding, + imageEmbeddingsList = page.map { record -> record.embedding }, + imageIdxList = page.map { record -> record.id }, + ) + afterId = page.last().id + } + return results.sortedByDescending { (_, score) -> score } + } + private suspend fun List.parseFuzzySearch(query: String): List> { - return withContext(Dispatchers.IO) { + return withContext(ioDispatcher) { if (query.isEmpty()) return@withContext emptyList() @@ -759,5 +904,17 @@ class SearchViewModel @Inject constructor( } } + private inline fun Bitmap.useForSearch(block: (Bitmap) -> T): T { + return try { + block(this) + } finally { + recycle() + } + } -} \ No newline at end of file + + private companion object { + private const val EMBEDDING_PAGE_SIZE = 256 + private const val TAG = "SearchViewModel" + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/helpers/SearchVisionHelper.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/helpers/SearchVisionHelper.kt index 4bc1aeffca..2f7667e46e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/helpers/SearchVisionHelper.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/helpers/SearchVisionHelper.kt @@ -2,9 +2,12 @@ package com.dot.gallery.feature_node.presentation.search.helpers import ai.onnxruntime.OnnxTensor import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtException import ai.onnxruntime.OrtSession import ai.onnxruntime.providers.NNAPIFlags import android.graphics.Bitmap +import com.dot.gallery.core.ml.ManagedOrtSession +import com.dot.gallery.core.ml.ModelInferenceException import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.core.ml.ModelsNotAvailableException import com.dot.gallery.feature_node.presentation.search.tokenizer.ClipTokenizer @@ -13,41 +16,89 @@ import com.dot.gallery.feature_node.presentation.search.util.normalizeL2 import com.dot.gallery.feature_node.presentation.search.util.preProcess import com.dot.gallery.feature_node.presentation.util.printDebug import java.nio.IntBuffer -import java.util.Collections import java.util.EnumSet class SearchVisionHelper(private val modelManager: ModelManager) { private val tokenizer by lazy { ClipTokenizer( - vocabFile = modelManager.getModelFile("vocab.json"), - mergesFile = modelManager.getModelFile("merges.txt") + vocabInputStreamProvider = { + modelManager.openBundledModelInputStream(name = "vocab.json") + }, + mergesInputStreamProvider = { + modelManager.openBundledModelInputStream(name = "merges.txt") + }, ) } private val ortEnv = OrtEnvironment.getEnvironment() - fun setupVisionSession() = createOrtSessionWithFallback("visual_quant.onnx") - fun setupTextSession() = createOrtSessionWithFallback("textual_quant.onnx") + fun setupVisionSession(): ManagedOrtSession { + return createOrtSessionWithFallback(modelName = "visual_quant.onnx") + } + + fun setupTextSession(): ManagedOrtSession { + return createOrtSessionWithFallback(modelName = "textual_quant.onnx") + } - private fun createOrtSessionWithFallback(modelName: String): OrtSession { + private fun createOrtSessionWithFallback(modelName: String): ManagedOrtSession { if (!modelManager.isReady) throw ModelsNotAvailableException() - val options = OrtSession.SessionOptions() + val isQuantized = modelName.contains("quant") + val options = OrtSession.SessionOptions() + var closeOptions = true try { - printDebug("Available providers: ${OrtEnvironment.getAvailableProviders()}") - // Try NNAPI (available on API 27+, our min is 29) - printDebug("Using NNAPI for inference") - options.addNnapi(EnumSet.of(NNAPIFlags.USE_FP16)) - } catch (e: Exception) { - printDebug("NNAPI not available, falling back to CPU: ${e.message}") - } + options.apply { + // Enable all graph optimizations (constant folding, op fusion, etc.) + setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + // Use multiple CPU threads for intra-op parallelism (e.g. matmul) + setIntraOpNumThreads( + Runtime.getRuntime().availableProcessors().coerceIn( + minimumValue = 2, + maximumValue = 4, + ), + ) + } - // Load model from filesDir using path-based API (memory-mapped, avoids OOM) - val modelFile = modelManager.getModelFile(modelName) - return ortEnv.createSession(modelFile.absolutePath, options) + // Only try NNAPI for non-quantized models. + // Quantized (INT8) models cause severe NNAPI overhead: + // - NNAPI compilation can take minutes for complex models like CLIP + // - Many quantized ops aren't NNAPI-supported, causing graph partitioning + // with expensive CPU/NNAPI memory copies at each boundary + // - USE_FP16 flag with INT8 model adds unnecessary type conversions + if (!isQuantized) { + try { + printDebug("Available providers: ${OrtEnvironment.getAvailableProviders()}") + printDebug("Using NNAPI for inference") + options.addNnapi(EnumSet.of(NNAPIFlags.USE_FP16)) + } catch (exception: Exception) { + printDebug("NNAPI not available, falling back to CPU: ${exception.message}") + } + } else { + printDebug("Using optimized CPU inference for quantized model: $modelName") + } + + val session = modelManager.withMappedBundledModel(name = modelName) { modelBuffer -> + ortEnv.createSession(modelBuffer, options) + } + closeOptions = false + return ManagedOrtSession( + environment = ortEnv, + session = session, + options = options, + ) + } catch (exception: OrtException) { + throw ModelInferenceException( + message = "Unable to create ONNX session for $modelName", + cause = exception, + ) + } finally { + if (closeOptions) { + options.close() + } + } } - fun getTextEmbedding(session: OrtSession, text: String): FloatArray { + fun getTextEmbedding(session: ManagedOrtSession, text: String): FloatArray { val tokenBOS = 49406 val tokenEOS = 49407 @@ -78,48 +129,85 @@ class SearchVisionHelper(private val modelManager: ModelManager) { inputIds.put(tokens[i]) } inputIds.rewind() - val inputIdsTensor = OnnxTensor.createTensor(ortEnv, inputIds, inputShape) - val attentionMask = IntBuffer.allocate(1 * 77) attentionMask.rewind() for (i in 0 until 77) { attentionMask.put(mask[i]) } attentionMask.rewind() - val attentionMaskTensor = OnnxTensor.createTensor(ortEnv, attentionMask, inputShape) - - val inputMap: MutableMap = HashMap() - inputMap["input_ids"] = inputIdsTensor - inputMap["attention_mask"] = attentionMaskTensor - - val output = session.run(inputMap) - output.use { - @Suppress("UNCHECKED_CAST") val rawOutput = - ((output?.get(0)?.value) as Array)[0] - return normalizeL2(rawOutput) + try { + return OnnxTensor.createTensor(session.environment, inputIds, inputShape).use { inputIdsTensor -> + OnnxTensor.createTensor(session.environment, attentionMask, inputShape).use { attentionMaskTensor -> + val inputMap = mapOf( + "input_ids" to inputIdsTensor, + "attention_mask" to attentionMaskTensor, + ) + session.run(inputs = inputMap) { result -> + normalizeL2(extractSingleFloatOutput(result = result)) + } + } + } + } catch (exception: OrtException) { + throw ModelInferenceException( + message = "Text model inference failed", + cause = exception, + ) } } - fun getImageEmbedding(session: OrtSession, bitmap: Bitmap): FloatArray { + fun getImageEmbedding(session: ManagedOrtSession, bitmap: Bitmap): FloatArray { val rawBitmap = centerCrop(bitmap, 224) val inputShape = longArrayOf(1, 3, 224, 224) val inputName = "pixel_values" val imgData = preProcess(rawBitmap) - val inputTensor = OnnxTensor.createTensor(ortEnv, imgData, inputShape) - - return inputTensor.use { - val output = session.run(Collections.singletonMap(inputName, inputTensor)) - output.use { - @Suppress("UNCHECKED_CAST") val rawOutput = - ((output?.get(0)?.value) as Array)[0] - return@use normalizeL2(rawOutput) + + try { + return OnnxTensor.createTensor(session.environment, imgData, inputShape).use { inputTensor -> + session.run(inputs = mapOf(inputName to inputTensor)) { result -> + normalizeL2(extractSingleFloatOutput(result = result)) + } } + } catch (exception: OrtException) { + throw ModelInferenceException( + message = "Image model inference failed", + cause = exception, + ) } } + private fun extractSingleFloatOutput(result: OrtSession.Result): FloatArray { + if (result.size() != 1) { + throw ModelInferenceException( + message = "Expected exactly one ONNX output, got ${result.size()}", + ) + } + + val output = try { + result.get(0).value + } catch (exception: OrtException) { + throw ModelInferenceException( + message = "Unable to read ONNX output", + cause = exception, + ) + } as? Array<*> + ?: throw ModelInferenceException( + message = "Expected ONNX output to be an array", + ) + if (output.size != 1) { + throw ModelInferenceException( + message = "Expected exactly one ONNX output batch, got ${output.size}", + ) + } + + return output[0] as? FloatArray + ?: throw ModelInferenceException( + message = "Expected ONNX output batch to be a float array", + ) + } + companion object { - const val threshold = 0.2 + const val THRESHOLD = 0.2f } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/tokenizer/ClipTokenizer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/tokenizer/ClipTokenizer.kt index 2cc173f258..8cbe513f23 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/tokenizer/ClipTokenizer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/tokenizer/ClipTokenizer.kt @@ -10,23 +10,22 @@ package com.dot.gallery.feature_node.presentation.search.tokenizer import android.util.JsonReader import java.io.BufferedReader -import java.io.File -import java.io.FileInputStream +import java.io.InputStream import java.io.InputStreamReader import kotlin.io.useLines import kotlin.use class ClipTokenizer( - private val vocabFile: File, - private val mergesFile: File, + private val vocabInputStreamProvider: () -> InputStream, + private val mergesInputStreamProvider: () -> InputStream, ) { private val encoder: Map = getVocab() private fun getVocab(): Map { val vocab = hashMapOf().apply { - FileInputStream(vocabFile).use { - val vocabReader = JsonReader(InputStreamReader(it, "UTF-8")) + vocabInputStreamProvider().use { inputStream -> + val vocabReader = JsonReader(InputStreamReader(inputStream, Charsets.UTF_8)) vocabReader.beginObject() while (vocabReader.hasNext()) { val key = vocabReader.nextName().replace("", " ") @@ -44,8 +43,8 @@ class ClipTokenizer( private fun getMerges(): HashMap, Int> { val merges = hashMapOf, Int>().apply { - FileInputStream(mergesFile).use { - val mergesReader = BufferedReader(InputStreamReader(it)) + mergesInputStreamProvider().use { inputStream -> + val mergesReader = BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)) mergesReader.useLines { seq -> seq.drop(1).forEachIndexed { i, s -> val list = s.split(" ") @@ -118,4 +117,4 @@ class ClipTokenizer( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtil.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtil.kt index 7fdb8a657b..dbce67d30a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtil.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtil.kt @@ -6,14 +6,26 @@ package com.dot.gallery.feature_node.presentation.search.util import kotlin.math.sqrt -infix fun FloatArray.dot(other: FloatArray) = - foldIndexed(0.0) { i, acc, cur -> acc + cur * other[i] }.toFloat() +infix fun FloatArray.dot(other: FloatArray): Float { + if (size != other.size || isEmpty()) { + return 0f + } + return foldIndexed(0.0) { index, accumulator, value -> + accumulator + value * other[index] + }.toFloat() +} fun normalizeL2(inputArray: FloatArray): FloatArray { + if (inputArray.isEmpty() || inputArray.any { value -> !value.isFinite() }) { + return FloatArray(size = inputArray.size) + } var norm = 0.0f for (i in inputArray.indices) { norm += inputArray[i] * inputArray[i] } norm = sqrt(norm) + if (norm == 0f || !norm.isFinite()) { + return FloatArray(size = inputArray.size) + } return inputArray.map { it / norm }.toFloatArray() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/loadModelFile.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/loadModelFile.kt deleted file mode 100644 index cbf72175ff..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/util/loadModelFile.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.dot.gallery.feature_node.presentation.search.util - -import android.content.Context -import java.io.File -import java.io.FileOutputStream - -fun loadModelFile(context: Context, assetName: String): File { - val file = File(context.cacheDir, assetName) - if (!file.exists()) { - context.assets.open(assetName).use { input -> - FileOutputStream(file).use { output -> - input.copyTo(output) - } - } - } - return file -} - -fun loadModelFromRaw(context: Context, resId: Int, fileName: String): File { - val file = File(context.cacheDir, fileName) - if (!file.exists()) { - context.resources.openRawResource(resId).use { input -> - FileOutputStream(file).use { output -> - input.copyTo(output) - } - } - } - return file -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewActivity.kt new file mode 100644 index 0000000000..7d046a609d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewActivity.kt @@ -0,0 +1,125 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.CompositionLocalProvider +import androidx.core.content.ContextCompat +import com.dot.gallery.feature_node.domain.securereview.AuthorizeSecureReviewRequest +import com.dot.gallery.feature_node.domain.securereview.IsDeviceLocked +import com.dot.gallery.feature_node.presentation.util.LocalHazeState +import com.dot.gallery.feature_node.presentation.util.toggleOrientation +import com.dot.gallery.ui.theme.GalleryTheme +import dagger.hilt.android.AndroidEntryPoint +import dev.chrisbanes.haze.rememberHazeState +import javax.inject.Inject + +@AndroidEntryPoint +class SecureReviewActivity : ComponentActivity() { + + @Inject + internal lateinit var authorizeSecureReviewRequest: AuthorizeSecureReviewRequest + + @Inject + internal lateinit var isDeviceLocked: IsDeviceLocked + + private var isReceiverRegistered = false + private val sessionEndReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_SCREEN_OFF, + Intent.ACTION_USER_PRESENT, + -> { + finishSecureReview() + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val request = authorizeSecureReviewRequest( + intent = intent, + caller = initialCaller, + ) + if (request == null || !isDeviceLocked()) { + finishSecureReview() + return + } + + configureSecureWindow() + registerSessionEndReceiver() + enableEdgeToEdge() + + setContent { + GalleryTheme { + val hazeState = rememberHazeState() + CompositionLocalProvider(LocalHazeState provides hazeState) { + SecureReviewScreen( + request = request, + onFinish = ::finishSecureReview, + onToggleOrientation = ::toggleOrientation, + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + finishSecureReview() + } + + override fun onResume() { + super.onResume() + + if (!isFinishing && !isDeviceLocked()) { + finishSecureReview() + } + } + + override fun onDestroy() { + if (isReceiverRegistered) { + unregisterReceiver(sessionEndReceiver) + isReceiverRegistered = false + } + + super.onDestroy() + } + + private fun registerSessionEndReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + } + + ContextCompat.registerReceiver( + this, + sessionEndReceiver, + filter, + ContextCompat.RECEIVER_EXPORTED, + ) + + isReceiverRegistered = true + } + + private fun configureSecureWindow() { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + setRecentsScreenshotEnabled(false) + setShowWhenLocked(true) + } + + private fun finishSecureReview() { + setShowWhenLocked(false) + if (!isFinishing) { + finishAndRemoveTask() + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEffect.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEffect.kt new file mode 100644 index 0000000000..1f17b35f58 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewEffect.kt @@ -0,0 +1,6 @@ +package com.dot.gallery.feature_node.presentation.securereview + +internal sealed interface SecureReviewEffect { + + data object Finish : SecureReviewEffect +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewScreen.kt new file mode 100644 index 0000000000..b859fddf8d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewScreen.kt @@ -0,0 +1,214 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import com.dot.gallery.R +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import com.dot.gallery.feature_node.presentation.mediaview.components.media.MediaPreviewComponent +import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayerController +import com.dot.gallery.ui.theme.GalleryTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun SecureReviewScreen( + request: AuthorizedSecureReviewRequest, + onFinish: () -> Unit, + onToggleOrientation: () -> Unit, + modifier: Modifier = Modifier, + screenModel: SecureReviewScreenModel = hiltViewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + val currentOnFinish by rememberUpdatedState(onFinish) + + LaunchedEffect(request, screenModel) { + screenModel.onLaunchRequest(request = request) + } + + LaunchedEffect(screenModel) { + screenModel.effects.collect { effect -> + when (effect) { + SecureReviewEffect.Finish -> currentOnFinish() + } + } + } + + BackHandler { + screenModel.onCloseClick() + } + + SecureReviewScreenContent( + uiState = uiState, + onClose = screenModel::onCloseClick, + onToggleOrientation = onToggleOrientation, + modifier = modifier, + ) +} + +@Composable +internal fun SecureReviewScreenContent( + uiState: SecureReviewUiState, + onClose: () -> Unit, + onToggleOrientation: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + when (uiState) { + SecureReviewUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + is SecureReviewUiState.Ready -> { + SecureReviewMediaPager( + media = uiState.media, + onClose = onClose, + onToggleOrientation = onToggleOrientation, + ) + } + } + + IconButton( + onClick = onClose, + modifier = Modifier + .align(Alignment.TopStart) + .systemBarsPadding(), + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(id = R.string.close), + ) + } + } +} + +@Composable +@OptIn(UnstableApi::class) +private fun SecureReviewMediaPager( + media: ImmutableList, + onClose: () -> Unit, + onToggleOrientation: () -> Unit, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = media::size) + + HorizontalPager( + state = pagerState, + modifier = modifier.fillMaxSize(), + beyondViewportPageCount = 0, + key = { index -> media[index].id }, + ) { page -> + val playWhenReady = remember(pagerState, page) { + derivedStateOf { pagerState.currentPage == page } + } + + MediaPreviewComponent( + media = media[page], + modifier = Modifier, + containerModifier = Modifier, + uiEnabled = true, + playWhenReady = playWhenReady, + onItemClick = {}, + onSwipeDown = onClose, + rotationDisabled = true, + onImageRotated = {}, + offset = IntOffset.Zero, + videoController = { + player, + isPlaying, + currentTime, + totalTime, + buffer, + frameRate, + -> + VideoPlayerController( + paddingValues = PaddingValues(), + player = player, + isPlaying = isPlaying, + currentTime = currentTime, + totalTime = totalTime, + buffer = buffer, + toggleRotate = onToggleOrientation, + frameRate = frameRate, + ) + }, + ) + } +} + +@Preview +@Composable +private fun SecureReviewScreenLoadingPreview() { + GalleryTheme { + SecureReviewScreenContent( + uiState = SecureReviewUiState.Loading, + onClose = {}, + onToggleOrientation = {}, + ) + } +} + +@Preview +@Composable +private fun SecureReviewScreenReadyPreview() { + GalleryTheme { + SecureReviewScreenContent( + uiState = SecureReviewUiState.Ready( + media = persistentListOf( + Media.UriMedia( + id = -1L, + label = "Preview image", + uri = Uri.parse("content://preview/image/1"), + path = "", + relativePath = "", + albumID = -99L, + albumLabel = "", + timestamp = 0L, + expiryTimestamp = null, + takenTimestamp = null, + fullDate = "", + mimeType = "image/jpeg", + favorite = 0, + trashed = 0, + size = 0L, + duration = null, + ), + ), + ), + onClose = {}, + onToggleOrientation = {}, + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewUiState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewUiState.kt new file mode 100644 index 0000000000..cf18416dc7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewUiState.kt @@ -0,0 +1,16 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import androidx.compose.runtime.Immutable +import com.dot.gallery.feature_node.data.model.Media +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface SecureReviewUiState { + + data object Loading : SecureReviewUiState + + @Immutable + data class Ready( + val media: ImmutableList, + ) : SecureReviewUiState +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModel.kt new file mode 100644 index 0000000000..0ca031f268 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModel.kt @@ -0,0 +1,90 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import com.dot.gallery.feature_node.data.repository.SecureReviewMediaRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +internal interface SecureReviewScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onLaunchRequest(request: AuthorizedSecureReviewRequest) + fun onCloseClick() +} + +@HiltViewModel +internal class SecureReviewViewModel @Inject constructor( + private val repository: SecureReviewMediaRepository, +) : ViewModel(), + SecureReviewScreenModel { + + private val effectsChannel = Channel(capacity = Channel.BUFFERED) + private val _uiState = MutableStateFlow(SecureReviewUiState.Loading) + + private var request: AuthorizedSecureReviewRequest? = null + private var loadJob: Job? = null + private var hasFinished = false + + override val effects = effectsChannel.receiveAsFlow() + override val uiState = _uiState.asStateFlow() + + override fun onLaunchRequest(request: AuthorizedSecureReviewRequest) { + if (this.request == request) { + return + } + + this.request = request + hasFinished = false + loadJob?.cancel() + _uiState.value = SecureReviewUiState.Loading + loadJob = viewModelScope.launch { + loadMedia(request = request) + } + } + + override fun onCloseClick() { + loadJob?.cancel() + sendFinishEffect() + } + + private suspend fun loadMedia(request: AuthorizedSecureReviewRequest) { + try { + val media = repository.loadMedia(request = request) + val isMediaIncomplete = media == null || media.size != request.uris.size + + when { + isMediaIncomplete -> sendFinishEffect() + else -> { + _uiState.value = SecureReviewUiState.Ready(media = media) + } + } + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + sendFinishEffect() + } + } + + private fun sendFinishEffect() { + if (hasFinished) { + return + } + + hasFinished = true + viewModelScope.launch { + effectsChannel.send(element = SecureReviewEffect.Finish) + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/security/BiometricManagerExt.kt similarity index 67% rename from app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/presentation/security/BiometricManagerExt.kt index 91eb7f45ef..314df7cbd8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/security/BiometricManagerExt.kt @@ -1,8 +1,8 @@ -package com.dot.gallery.feature_node.presentation.vault.utils +package com.dot.gallery.feature_node.presentation.security import android.app.KeyguardManager -import android.content.Context import android.os.Build +import androidx.activity.compose.LocalActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL @@ -19,7 +19,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity @Composable -fun rememberBiometricManager(): BiometricManager { +internal fun rememberBiometricManager(): BiometricManager { val context = LocalContext.current return remember(context) { BiometricManager.from(context) @@ -27,9 +27,9 @@ fun rememberBiometricManager(): BiometricManager { } @Composable -fun rememberBiometricCallback( +internal fun rememberBiometricCallback( onSuccess: () -> Unit, - onFailed: () -> Unit + onFailed: () -> Unit, ): BiometricPrompt.AuthenticationCallback { val currentOnSuccess by rememberUpdatedState(onSuccess) val currentOnFailed by rememberUpdatedState(onFailed) @@ -44,25 +44,26 @@ fun rememberBiometricCallback( super.onAuthenticationSucceeded(result) currentOnSuccess() } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - } } } } @Composable -fun rememberBiometricState( +internal fun rememberBiometricState( title: String, subtitle: String, onSuccess: () -> Unit, - onFailed: () -> Unit + onFailed: () -> Unit, ): BiometricState { - val context = LocalContext.current + val activity = LocalActivity.current as? FragmentActivity + val biometricManager = rememberBiometricManager() - val callback = rememberBiometricCallback(onSuccess, onFailed) - return remember(biometricManager, title, subtitle) { + val callback = rememberBiometricCallback( + onSuccess = onSuccess, + onFailed = onFailed, + ) + + return remember(activity, biometricManager, title, subtitle) { val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { PromptInfo.Builder() .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) @@ -78,37 +79,38 @@ fun rememberBiometricState( .build() } BiometricState( - activity = context as FragmentActivity, + activity = activity, biometricManager = biometricManager, promptInfo = promptInfo, - callback = callback + callback = callback, ) } } -class BiometricState( - private val activity: FragmentActivity, +internal class BiometricState( + private val activity: FragmentActivity?, biometricManager: BiometricManager, private val promptInfo: PromptInfo, - private val callback: BiometricPrompt.AuthenticationCallback + private val callback: BiometricPrompt.AuthenticationCallback, ) { val isSupported by mutableStateOf( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS - } else { - val keyguardManager = activity.getSystemService(KeyguardManager::class.java) - keyguardManager?.isDeviceSecure == true - } + activity?.let { currentActivity -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + } else { + val keyguardManager = currentActivity.getSystemService(KeyguardManager::class.java) + keyguardManager?.isDeviceSecure == true + } + } == true ) fun authenticate() { if (isSupported) { - // Create a fresh BiometricPrompt each time to avoid stale internal - // BiometricFragment state that silently swallows subsequent callbacks. - val executor = ContextCompat.getMainExecutor(activity) - val prompt = BiometricPrompt(activity, executor, callback) + val currentActivity = activity ?: return + + val executor = ContextCompat.getMainExecutor(currentActivity) + val prompt = BiometricPrompt(currentActivity, executor, callback) prompt.authenticate(promptInfo) } } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt index 0f4ccd57ff..07a94b2684 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt @@ -6,12 +6,13 @@ package com.dot.gallery.feature_node.presentation.settings import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Fullscreen import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.Palette -import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.SettingsSuggest import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -92,6 +93,15 @@ fun SettingsScreen() { }, screenPosition = Position.Middle ) + val securityPref = rememberPreference( + icon = Icons.Outlined.Security, + title = stringResource(R.string.settings_security), + summary = stringResource(R.string.settings_security_summary), + onClick = { + eventHandler.navigate(Screen.SettingsSecurityScreen()) + }, + screenPosition = Position.Middle + ) val helpPref = rememberPreference( icon = Icons.AutoMirrored.Outlined.HelpOutline, title = stringResource(R.string.help_title), @@ -103,11 +113,11 @@ fun SettingsScreen() { ) return remember( appearancePref, timelineAlbumsPref, mediaViewerPref, - navigationPref, generalPref, smartPref, helpPref + navigationPref, generalPref, smartPref, securityPref, helpPref ) { mutableStateListOf( appearancePref, timelineAlbumsPref, mediaViewerPref, - navigationPref, generalPref, smartPref, helpPref + navigationPref, generalPref, smartPref, securityPref, helpPref ) } } @@ -117,15 +127,28 @@ fun SettingsScreen() { val tertiaryColor = MaterialTheme.colorScheme.tertiary val errorColor = MaterialTheme.colorScheme.error val primaryContainerColor = MaterialTheme.colorScheme.primaryContainer + val secondaryContainerColor = MaterialTheme.colorScheme.secondaryContainer val tertiaryContainerColor = MaterialTheme.colorScheme.tertiaryContainer val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant val backgroundColors = remember( - primaryColor, secondaryColor, tertiaryColor, - errorColor, primaryContainerColor, tertiaryContainerColor, surfaceVariantColor + primaryColor, + secondaryColor, + tertiaryColor, + errorColor, + primaryContainerColor, + secondaryContainerColor, + tertiaryContainerColor, + surfaceVariantColor, ) { listOf( - primaryColor, secondaryColor, tertiaryColor, - errorColor, primaryContainerColor, tertiaryContainerColor, surfaceVariantColor + primaryColor, + secondaryColor, + tertiaryColor, + errorColor, + primaryContainerColor, + secondaryContainerColor, + tertiaryContainerColor, + surfaceVariantColor, ) } val onPrimaryColor = MaterialTheme.colorScheme.onPrimary @@ -133,15 +156,28 @@ fun SettingsScreen() { val onTertiaryColor = MaterialTheme.colorScheme.onTertiary val onErrorColor = MaterialTheme.colorScheme.onError val onPrimaryContainerColor = MaterialTheme.colorScheme.onPrimaryContainer + val onSecondaryContainerColor = MaterialTheme.colorScheme.onSecondaryContainer val onTertiaryContainerColor = MaterialTheme.colorScheme.onTertiaryContainer val onSurfaceVariantColor = MaterialTheme.colorScheme.onSurfaceVariant val onBackgroundColors = remember( - onPrimaryColor, onSecondaryColor, onTertiaryColor, - onErrorColor, onPrimaryContainerColor, onTertiaryContainerColor, onSurfaceVariantColor + onPrimaryColor, + onSecondaryColor, + onTertiaryColor, + onErrorColor, + onPrimaryContainerColor, + onSecondaryContainerColor, + onTertiaryContainerColor, + onSurfaceVariantColor, ) { listOf( - onPrimaryColor, onSecondaryColor, onTertiaryColor, - onErrorColor, onPrimaryContainerColor, onTertiaryContainerColor, onSurfaceVariantColor + onPrimaryColor, + onSecondaryColor, + onTertiaryColor, + onErrorColor, + onPrimaryContainerColor, + onSecondaryContainerColor, + onTertiaryContainerColor, + onSurfaceVariantColor, ) } BaseSettingsScreen( @@ -154,12 +190,13 @@ fun SettingsScreen() { SettingsItem( item = setting, customIcon = { icon, iconUri, iconRes -> + val colorIndex = index % backgroundColors.size CustomCircleIcon( iconVector = icon, iconUri = iconUri, iconRes = iconRes, - containerColor = backgroundColors[index], - contentColor = onBackgroundColors[index] + containerColor = backgroundColors[colorIndex], + contentColor = onBackgroundColors[colorIndex] ) } ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/AppHeader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/AppHeader.kt index fd565df741..d1aa61db8e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/AppHeader.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/AppHeader.kt @@ -35,21 +35,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.dot.gallery.BuildConfig -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.support.SupportSheet -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.BuildConfig +import com.dot.gallery.R +import com.dot.gallery.feature_node.presentation.support.SupportSheet +import com.dot.gallery.feature_node.presentation.util.launchViewUri +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.ui.theme.GalleryTheme import kotlinx.coroutines.launch @@ -69,7 +71,7 @@ fun SettingsAppHeader() { val githubContentDesc = stringResource(R.string.github_button_cd) val githubUrl = stringResource(R.string.github_url) - val uriHandler = LocalUriHandler.current + val context = LocalContext.current val scope = rememberCoroutineScope() val supportState = rememberAppBottomSheetState() @@ -179,10 +181,13 @@ fun SettingsAppHeader() { ) { Icon(painter = donateImage, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text(text = donateTitle) + Text( + text = donateTitle, + textAlign = TextAlign.Center, + ) } Button( - onClick = { uriHandler.openUri(githubUrl) }, + onClick = { context.launchViewUri(githubUrl) }, colors = ButtonDefaults.buttonColors( contentColor = MaterialTheme.colorScheme.onTertiary, disabledContentColor = MaterialTheme.colorScheme.onTertiary.copy(alpha = .12f), @@ -220,4 +225,4 @@ fun Preview() { SettingsAppHeader() } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/PreferenceDetailScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/PreferenceDetailScreen.kt index 723f5f5cc6..49d5d7e7c6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/PreferenceDetailScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/PreferenceDetailScreen.kt @@ -15,11 +15,11 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -60,6 +60,7 @@ data class PreferenceOption( val value: T, val label: String, val isSelected: Boolean, + val summary: String? = null, ) /** @@ -406,6 +407,7 @@ fun ChooserPreferenceDetailScreen( SettingsItem( item = SettingsEntity.Preference( title = option.label, + summary = option.summary, onClick = { onOptionSelected?.invoke(option.value) }, screenPosition = optionPosition ), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt index 0df2bacea6..910409501d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt @@ -1147,4 +1147,4 @@ private fun SettingsItemPreview() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/AIModelsManagerScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/AIModelsManagerScreen.kt deleted file mode 100644 index 729f88087a..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/AIModelsManagerScreen.kt +++ /dev/null @@ -1,481 +0,0 @@ -package com.dot.gallery.feature_node.presentation.settings.subsettings - -import android.text.format.Formatter -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.R -import com.dot.gallery.core.ml.ModelStatus -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.presentation.settings.components.settings - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AIModelsManagerScreen( - viewModel: SmartFeaturesViewModel = hiltViewModel() -) { - val context = LocalContext.current - val modelStatus by viewModel.modelStatus.collectAsStateWithLifecycle() - val downloadProgress by viewModel.downloadProgress.collectAsStateWithLifecycle() - val downloadInfo by viewModel.downloadInfo.collectAsStateWithLifecycle() - val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() - - var showDeleteDialog by remember { mutableStateOf(false) } - - val scrollBehavior = - TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - title = { Text(stringResource(R.string.ai_models_manager)) }, - navigationIcon = { NavigationBackButton() }, - scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface - ) - ) - } - ) { padding -> - // Resolve all strings outside the non-composable settings{} DSL - val descriptionText = stringResource(R.string.ai_models_description) + "\n\n" + - stringResource(R.string.ai_models_privacy_description) - val sourceHeader = stringResource(R.string.ai_models_source) - val sourceUrl = "https://github.com/IacobIonut01/ReFra/tree/main/ml-models/src/main/assets" - val sourceLabel = stringResource(R.string.ai_models_source_url) - val filesHeader = stringResource(R.string.ai_models_files) - - // Action preference strings - val actionTitle: String - val actionSummary: String - val actionEnabled: Boolean - val actionClick: () -> Unit - when (modelStatus) { - ModelStatus.READY -> { - val sizeStr = Formatter.formatFileSize(context, viewModel.installedSize) - actionTitle = stringResource(R.string.ai_models_delete) - actionSummary = stringResource(R.string.ai_models_ready_summary) + "\n" + - stringResource(R.string.ai_models_size, sizeStr) - actionEnabled = true - actionClick = { showDeleteDialog = true } - } - ModelStatus.DOWNLOADING, ModelStatus.COPYING -> { - actionTitle = stringResource(R.string.ai_models_downloading) - val speedStr = Formatter.formatFileSize(context, downloadInfo.speed) - val downloadedStr = Formatter.formatFileSize(context, downloadInfo.downloadedBytes) - val totalStr = Formatter.formatFileSize(context, downloadInfo.totalBytes) - val etaStr = if (downloadInfo.speed > 0 && downloadInfo.totalBytes > 0) { - val remainingBytes = downloadInfo.totalBytes - downloadInfo.downloadedBytes - val remainingSecs = remainingBytes / downloadInfo.speed - formatDuration(remainingSecs) - } else "" - actionSummary = buildString { - append(downloadInfo.currentFile) - if (downloadInfo.totalBytes > 0) append(" · $downloadedStr / $totalStr") - if (downloadInfo.speed > 0) append(" · $speedStr/s") - if (etaStr.isNotEmpty()) append(" · $etaStr") - } - actionEnabled = true - actionClick = { viewModel.cancelDownload() } - } - ModelStatus.ERROR -> { - actionTitle = stringResource(R.string.ai_models_download) - actionSummary = errorMessage ?: "Unknown error" - actionEnabled = true - actionClick = { viewModel.downloadModels() } - } - ModelStatus.NOT_INSTALLED -> { - actionTitle = stringResource(R.string.ai_models_download) - actionSummary = stringResource(R.string.ai_models_download_summary) - actionEnabled = true - actionClick = { viewModel.downloadModels() } - } - } - - // File infos (computed only when READY) - val fileInfos = remember(modelStatus) { - if (modelStatus == ModelStatus.READY) viewModel.getFileInfos() else emptyList() - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = padding.calculateStartPadding(LocalLayoutDirection.current), - end = padding.calculateEndPadding(LocalLayoutDirection.current), - top = 16.dp + padding.calculateTopPadding(), - bottom = padding.calculateBottomPadding() + 16.dp - ) - ) { - // Description (includes privacy info) - item(key = "description") { - Text( - text = descriptionText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .widthIn(max = 600.dp) - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp) - ) - } - - // Feature previews - item(key = "feature_previews") { - Row( - modifier = Modifier - .widthIn(max = 600.dp) - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - FeaturePreviewCard( - label = stringResource(R.string.ai_models_feature_search), - modifier = Modifier.weight(1f).fillMaxHeight() - ) { - SearchPreview() - } - FeaturePreviewCard( - label = stringResource(R.string.ai_models_feature_categories), - modifier = Modifier.weight(1f).fillMaxHeight() - ) { - CategoriesPreview() - } - } - } - - // Source (always visible, clickable) - settings { - Header(sourceHeader) - - Preference( - title = sourceLabel, - summary = sourceUrl, - onClick = { - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(sourceUrl) - ) - context.startActivity(intent) - } - ) - } - - // Action button with integrated status + progress bar - item(key = "action") { - val isDownloading = modelStatus == ModelStatus.DOWNLOADING || modelStatus == ModelStatus.COPYING - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .clickable(enabled = actionEnabled) { actionClick() } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = actionTitle, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - if (isDownloading) { - LinearProgressIndicator( - progress = { downloadProgress / 100f }, - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)), - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - } - Text( - text = actionSummary, - style = MaterialTheme.typography.bodySmall, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // File details when installed (full SHA-256 + verified status) - if (fileInfos.isNotEmpty()) { - settings { - Header(filesHeader) - - fileInfos.forEach { info -> - val fileSizeStr = Formatter.formatFileSize(context, info.size) - val verifiedStr = if (info.verified) "(Verified)" else "(Unverified)" - Preference( - title = info.name, - summary = "$fileSizeStr\nSHA-256: ${info.sha256}\n$verifiedStr" - ) - } - } - } - } - } - - if (showDeleteDialog) { - AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text(stringResource(R.string.ai_models_delete)) }, - text = { Text(stringResource(R.string.ai_models_delete_confirm)) }, - confirmButton = { - TextButton( - onClick = { - showDeleteDialog = false - viewModel.deleteModels() - } - ) { - Text( - stringResource(R.string.action_confirm), - color = MaterialTheme.colorScheme.error - ) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } -} - -private fun formatDuration(seconds: Long): String { - return when { - seconds < 60 -> "${seconds}s" - seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" - else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" - } -} - -@Composable -private fun FeaturePreviewCard( - label: String, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - Column( - modifier = modifier - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - content() - } - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp, bottom = 12.dp) - ) - } -} - -@Composable -private fun SearchPreview() { - val cellColor = MaterialTheme.colorScheme.surfaceContainerHighest - val matchColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) - val headerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Search bar - Box( - modifier = Modifier - .fillMaxWidth() - .height(20.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - contentAlignment = Alignment.CenterStart - ) { - Box( - modifier = Modifier - .padding(start = 8.dp) - .size(48.dp, 5.dp) - .clip(RoundedCornerShape(2.dp)) - .background(headerColor) - ) - } - Spacer(Modifier.height(2.dp)) - // Photo grid — best matches first (sequential at top) - repeat(3) { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - repeat(4) { col -> - val index = row * 4 + col - val isMatch = index < 4 - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)) - .background(if (isMatch) matchColor else cellColor) - ) { - if (isMatch) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(2.dp) - .size(6.dp) - .clip(RoundedCornerShape(1.dp)) - .background(MaterialTheme.colorScheme.primary) - ) - } - } - } - } - } - } -} - -@Composable -private fun CategoriesPreview() { - val cardColors = listOf( - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.tertiaryContainer, - MaterialTheme.colorScheme.surfaceVariant, - ) - val categories = listOf("Nature", "Food", "People", "Travel", "Pets") - val fadeColor = MaterialTheme.colorScheme.surfaceContainer - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - drawRect( - brush = Brush.horizontalGradient( - colors = listOf(Color.Transparent, fadeColor), - startX = size.width * 0.75f, - endX = size.width - ), - blendMode = BlendMode.SrcAtop - ) - } - ) { - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - categories.forEachIndexed { idx, label -> - MiniCategoryCard( - name = label, - backgroundColor = cardColors[idx % cardColors.size] - ) - } - } - } -} - -@Composable -private fun MiniCategoryCard( - name: String, - backgroundColor: Color, -) { - Box( - modifier = Modifier - .width(56.dp) - .aspectRatio(164f / 256f) - .clip(RoundedCornerShape(10.dp)) - .background(backgroundColor) - ) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.55f)) - ) - ) - .padding(horizontal = 4.dp, vertical = 6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = name, - style = MaterialTheme.typography.labelSmall, - fontSize = 7.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - maxLines = 1 - ) - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewModel.kt deleted file mode 100644 index 821563c73e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewModel.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.settings.subsettings - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.dot.gallery.core.EditBackupManager -import com.dot.gallery.core.workers.EditBackupWorker -import com.dot.gallery.core.workers.autoCleanupEditBackups -import com.dot.gallery.core.workers.deleteAllEditBackups -import com.dot.gallery.core.workers.deleteSelectedEditBackups -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class EditBackupsViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val editBackupManager: EditBackupManager, - private val workManager: WorkManager, - private val repository: MediaRepository -) : ViewModel() { - - data class State( - val isLoading: Boolean = true, - val totalSize: Long = 0L, - val freeSpace: Long = 0L, - val backups: List = emptyList(), - val mediaList: List = emptyList() - ) - - private val _state = MutableStateFlow(State()) - val state: StateFlow = _state.asStateFlow() - - init { - refresh() - } - - fun refresh() { - viewModelScope.launch(Dispatchers.IO) { - val info = editBackupManager.getAllBackupInfo() - .sortedByDescending { it.editTimestamp } - val size = editBackupManager.getTotalBackupSize() - val free = context.filesDir.freeSpace - - val backupUris = info.map { it.originalUri } - val mediaList = if (backupUris.isNotEmpty()) { - val resource = repository.getMediaListByUris( - listOfUris = backupUris, - reviewMode = false, - onlyMatching = true - ).first() - val allMedia = resource.data ?: emptyList() - val backupMediaIds = info.map { it.mediaId }.toSet() - allMedia.filter { it.id in backupMediaIds } - } else emptyList() - - _state.value = State( - isLoading = false, - totalSize = size, - freeSpace = free, - backups = info, - mediaList = mediaList - ) - } - } - - fun autoCleanup(onResult: (Int) -> Unit) { - viewModelScope.launch { - val workId = workManager.autoCleanupEditBackups() - workManager.getWorkInfoByIdFlow(workId).collect { info -> - if (info == null) return@collect - when (info.state) { - WorkInfo.State.SUCCEEDED -> { - val count = info.outputData.getInt( - EditBackupWorker.KEY_DELETED_COUNT, 0 - ) - refresh() - onResult(count) - return@collect - } - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - refresh() - onResult(0) - return@collect - } - else -> { /* waiting */ } - } - } - } - } - - fun deleteAll(onDone: () -> Unit = {}) { - viewModelScope.launch { - val workId = workManager.deleteAllEditBackups() - workManager.getWorkInfoByIdFlow(workId).collect { info -> - if (info == null) return@collect - if (info.state.isFinished) { - refresh() - onDone() - return@collect - } - } - } - } - - fun deleteSelected(mediaIds: List, onDone: () -> Unit = {}) { - viewModelScope.launch { - val workId = workManager.deleteSelectedEditBackups(mediaIds.toLongArray()) - workManager.getWorkInfoByIdFlow(workId).collect { info -> - if (info == null) return@collect - if (info.state.isFinished) { - refresh() - onDone() - return@collect - } - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewerScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewerScreen.kt deleted file mode 100644 index cf7d31d152..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/EditBackupsViewerScreen.kt +++ /dev/null @@ -1,850 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.settings.subsettings - -import android.os.Build -import android.text.format.Formatter -import androidx.activity.ComponentActivity -import androidx.activity.compose.LocalActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerDefaults -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.Image -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.RadioButtonUnchecked -import androidx.compose.material.icons.outlined.AutoDelete -import androidx.compose.material.icons.outlined.DeleteForever -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.launch -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.view.WindowCompat -import androidx.compose.ui.layout.ContentScale -import java.io.File -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.github.panpf.zoomimage.compose.glide.ExperimentalGlideComposeApi as ZoomExperimentalGlideComposeApi -import com.dot.gallery.core.Settings -import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus -import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus -import com.dot.gallery.core.presentation.components.util.swipe -import com.dot.gallery.feature_node.presentation.mediaview.components.media.BlurredMediaBackground -import com.github.panpf.zoomimage.GlideZoomAsyncImage -import com.github.panpf.zoomimage.rememberGlideZoomState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import androidx.compose.animation.core.tween -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.R -import com.dot.gallery.core.Constants.Animation.enterAnimation -import com.dot.gallery.core.Constants.Animation.exitAnimation -import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.presentation.mediaview.components.media.MediaPreviewComponent -import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState -import com.dot.gallery.feature_node.presentation.common.components.OptionItem -import com.dot.gallery.feature_node.presentation.common.components.OptionSheet -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.ui.theme.BlackScrim -import kotlinx.coroutines.flow.collectLatest - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) -@Composable -fun EditBackupsViewerScreen( - paddingValues: PaddingValues = PaddingValues(0.dp) -) { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - val eventHandler = LocalEventHandler.current - val context = LocalContext.current - val activity = LocalActivity.current - - val mediaList = state.mediaList - val backups = state.backups - - // Pager state - val pagerState = rememberPagerState( - initialPage = 0, - pageCount = { mediaList.size } - ) - - var currentPage by rememberSaveable { mutableIntStateOf(0) } - val currentMedia by rememberedDerivedState(mediaList, currentPage) { - mediaList.getOrNull(currentPage) - } - - LaunchedEffect(mediaList) { - snapshotFlow { pagerState.currentPage }.collectLatest { page -> - currentPage = page - } - } - - // UI visibility - var showUI by rememberSaveable { mutableStateOf(true) } - - // Header expansion: 0f = collapsed, 1f = expanded - var headerExpansion by rememberSaveable { mutableFloatStateOf(0f) } - val animatedExpansion by animateFloatAsState( - targetValue = headerExpansion, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "headerExpansion" - ) - - // Selection state - var selectionMode by rememberSaveable { mutableStateOf(false) } - var selectedIds by rememberSaveable { mutableStateOf(emptySet()) } - - // Original vs Edited toggle state (keyed by mediaId) - var showingOriginalIds by rememberSaveable { mutableStateOf(emptySet()) } - val backupsByMediaId = remember(backups) { - backups.associateBy { it.mediaId } - } - - // Delete confirmation dialog - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } - - // Action menu - var showActionMenu by rememberSaveable { mutableStateOf(false) } - - // Storage percentage - val usable = state.totalSize + state.freeSpace - val usedPercent = if (usable > 0) { - "%.1f".format((state.totalSize.toFloat() / usable) * 100f) - } else "0" - val storageFraction = if (usable > 0) { - (state.totalSize.toFloat() / usable).coerceIn(0f, 1f) - } else 0f - - // Insets - val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val systemBarsPadding = WindowInsets.systemBars.asPaddingValues() - - val isDark = com.dot.gallery.ui.theme.isDarkTheme() - - if (state.isLoading || mediaList.isEmpty()) { - // Status bar follows theme in empty state - DisposableEffect(isDark) { - val window = (activity as? ComponentActivity)?.window ?: return@DisposableEffect onDispose {} - val insetsController = WindowCompat.getInsetsController(window, window.decorView) - val wasLight = insetsController.isAppearanceLightStatusBars - insetsController.isAppearanceLightStatusBars = !isDark - onDispose { - insetsController.isAppearanceLightStatusBars = wasLight - } - } - // Empty / loading state - Box( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - if (state.isLoading) { - LinearProgressIndicator() - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Outlined.Image, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Text( - text = stringResource(R.string.edit_backups_no_backups), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = stringResource(R.string.edit_backups_no_backups_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } - // Back button overlay - IconButton( - modifier = Modifier - .align(Alignment.TopStart) - .padding(top = statusBarPadding + 8.dp, start = 12.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = CircleShape - ), - onClick = { - runCatching { - (activity as ComponentActivity).onBackPressedDispatcher.onBackPressed() - }.getOrElse { eventHandler.navigateUpAction() } - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back_cd), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - return - } - - // Force light status bar icons (white) on dark background for media viewer - DisposableEffect(Unit) { - val window = (activity as? ComponentActivity)?.window ?: return@DisposableEffect onDispose {} - val insetsController = WindowCompat.getInsetsController(window, window.decorView) - val wasLight = insetsController.isAppearanceLightStatusBars - insetsController.isAppearanceLightStatusBars = false - onDispose { - insetsController.isAppearanceLightStatusBars = wasLight - } - } - - // Main content — always dark regardless of system theme - val backgroundColor = Color.Black - val accentColor = MaterialTheme.colorScheme.primary - - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - ) { - // ── Media Pager ── - // Top padding animates with card expansion so media slides under the card - val pagerTopPadding by animateDpAsState( - targetValue = statusBarPadding + 64.dp + (160.dp * animatedExpansion), - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "pagerTopPadding" - ) - HorizontalPager( - modifier = Modifier - .fillMaxSize() - .padding(top = pagerTopPadding) - .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), - state = pagerState, - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - snapAnimationSpec = spring(stiffness = Spring.StiffnessMedium), - snapPositionalThreshold = 0.3f - ), - key = { index -> mediaList.getOrNull(index)?.id ?: "empty_$index" }, - pageSpacing = 16.dp, - beyondViewportPageCount = 0 - ) { index -> - val media by rememberedDerivedState(mediaList, index) { - mediaList.getOrNull(index) - } - val canPlay = rememberSaveable(media) { mutableStateOf(false) } - val mediaId = media?.id ?: -1L - val isShowingOriginal = mediaId in showingOriginalIds - val backupInfo = backupsByMediaId[mediaId] - - AnimatedVisibility( - visible = media != null, - enter = fadeIn(), - exit = fadeOut() - ) { - Box(modifier = Modifier.fillMaxSize()) { - Crossfade( - targetState = isShowingOriginal, - label = "originalEditedCrossfade" - ) { showOriginal -> - if (showOriginal && backupInfo != null) { - Box(modifier = Modifier - .fillMaxSize() - .hazeSource(state = LocalHazeState.current) - ) { - // Blurred background for original (file-based) - @OptIn(ExperimentalGlideComposeApi::class) - ProvideBatteryStatus { - val allowBlur by Settings.Misc.rememberAllowBlur() - val isPowerSavingMode = LocalBatteryStatus.current.isPowerSavingMode - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && allowBlur && !isPowerSavingMode) { - val blurAlpha by animateFloatAsState( - animationSpec = tween(DEFAULT_TOP_BAR_ANIMATION_DURATION), - targetValue = if (showUI) 0.7f else 0f, - label = "blurAlpha" - ) - GlideImage( - modifier = Modifier - .fillMaxSize() - .alpha(blurAlpha) - .blur(100.dp), - model = File(backupInfo.backupPath), - contentDescription = null, - contentScale = ContentScale.Crop - ) - } - } - // Zoomable original image - @OptIn(ExperimentalGlideComposeApi::class, ZoomExperimentalGlideComposeApi::class) - GlideZoomAsyncImage( - zoomState = rememberGlideZoomState(), - model = File(backupInfo.backupPath), - modifier = Modifier - .fillMaxSize() - .swipe( - onSwipeDown = { - runCatching { - (activity as ComponentActivity).onBackPressedDispatcher.onBackPressed() - }.getOrElse { eventHandler.navigateUpAction() } - } - ), - onTap = { showUI = !showUI }, - alignment = Alignment.Center, - contentDescription = stringResource(R.string.edit_backups_viewing_original), - scrollBar = null - ) - } - } else { - var offset by remember { mutableStateOf(IntOffset(0, 0)) } - MediaPreviewComponent( - modifier = Modifier, - media = media, - uiEnabled = showUI, - playWhenReady = canPlay, - onSwipeDown = { - runCatching { - (activity as ComponentActivity).onBackPressedDispatcher.onBackPressed() - }.getOrElse { eventHandler.navigateUpAction() } - }, - offset = offset, - isPanorama = false, - isPhotosphere = false, - isMotionPhoto = false, - motionPhotoState = null, - currentVault = null, - rotationDisabled = true, - onImageRotated = {}, - onItemClick = { - showUI = !showUI - } - ) { _, _, _, _, _, _ -> - // No video controller overlay for backups viewer - } - } - } - } - } - } - - // ── Drag gesture area for header expansion ── - Box( - modifier = Modifier - .fillMaxWidth() - .height(statusBarPadding + 120.dp + (140.dp * animatedExpansion)) - .align(Alignment.TopCenter) - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragEnd = { - headerExpansion = if (headerExpansion > 0.5f) 1f else 0f - }, - onVerticalDrag = { _, dragAmount -> - val delta = dragAmount / 400f - headerExpansion = (headerExpansion + delta).coerceIn(0f, 1f) - } - ) - } - ) - - // ── Top Bar ── - AnimatedVisibility( - visible = showUI, - enter = enterAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), - exit = exitAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION) - ) { - val gradientColor by animateColorAsState(BlackScrim) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf(gradientColor, Color.Transparent) - ) - ) - .padding(top = statusBarPadding) - ) { - // Row: Back | Center text | Action menu - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val buttonBackground = Color.White.copy(alpha = 0.12f) - - // Back button - IconButton( - modifier = Modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(color = buttonBackground, shape = CircleShape), - onClick = { - runCatching { - (activity as ComponentActivity).onBackPressedDispatcher.onBackPressed() - }.getOrElse { eventHandler.navigateUpAction() } - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back_cd), - tint = Color.White - ) - } - - // Center: Used space (collapsed only) - val centerAlpha by animateFloatAsState( - targetValue = 1f - animatedExpansion, - label = "centerAlpha" - ) - Text( - text = stringResource( - R.string.edit_backups_used_percent, - usedPercent - ), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier - .weight(1f) - .graphicsLayer { alpha = centerAlpha } - ) - - // Action menu button - IconButton( - modifier = Modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(color = buttonBackground, shape = CircleShape), - onClick = { showActionMenu = true } - ) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.edit_backups_section_actions), - tint = Color.White - ) - } - } - - // ── Expanded storage card ── - val cardAlpha by animateFloatAsState( - targetValue = animatedExpansion, - label = "cardAlpha" - ) - val cardColor = Color.White.copy(alpha = 0.1f) - if (animatedExpansion > 0.05f) { - Column( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = cardAlpha } - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp) - .clip(RoundedCornerShape(20.dp)) - .background(color = cardColor) - .padding(16.dp) - .animateContentSize(), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = stringResource(R.string.edit_backups_storage), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) - - LinearProgressIndicator( - progress = { storageFraction }, - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = accentColor, - trackColor = Color.White.copy(alpha = 0.12f), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = Formatter.formatShortFileSize(context, state.totalSize), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = accentColor - ) - Text( - text = stringResource( - R.string.edit_backups_storage_summary, - Formatter.formatShortFileSize(context, state.totalSize), - backups.size - ), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f) - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = Formatter.formatShortFileSize(context, state.freeSpace), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color.White - ) - Text( - text = stringResource(R.string.edit_backups_free_space), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f) - ) - } - } - } - } - } - } - - // ── Bottom bar: Selection (left) + Segmented toggle (right) ── - AnimatedVisibility( - visible = showUI, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), - modifier = Modifier.align(Alignment.BottomCenter) - ) { - val isCurrentSelected = currentMedia?.let { it.id in selectedIds } ?: false - val currentMediaId = currentMedia?.id ?: -1L - val isShowingOriginal = currentMediaId in showingOriginalIds - val hasBackup = backupsByMediaId.containsKey(currentMediaId) - val pillShape = RoundedCornerShape(100) - - val hazeState = LocalHazeState.current - val hazeStyle = HazeMaterials.ultraThin( - containerColor = BlackScrim - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // ── Selection pill (left) ── - Row( - modifier = Modifier - .fillMaxHeight() - .clip(pillShape) - .hazeEffect(state = hazeState, style = hazeStyle) - .background(BlackScrim) - .padding(horizontal = 4.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Select button — icon + text as single clickable - Row( - modifier = Modifier - .clip(pillShape) - .background( - if (isCurrentSelected) Color.White.copy(alpha = 0.15f) - else Color.Transparent - ) - .clickable { - if (!selectionMode) { - selectionMode = true - } - currentMedia?.let { media -> - selectedIds = if (media.id in selectedIds) { - val newSet = selectedIds - media.id - if (newSet.isEmpty()) selectionMode = false - newSet - } else { - selectedIds + media.id - } - } - } - .padding(start = 10.dp, end = 14.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = if (isCurrentSelected) Icons.Outlined.CheckCircle - else Icons.Outlined.RadioButtonUnchecked, - contentDescription = stringResource(R.string.edit_backups_select), - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - Text( - text = if (selectionMode && selectedIds.isNotEmpty()) { - stringResource( - R.string.edit_backups_selected_count, - selectedIds.size - ) - } else { - stringResource(R.string.edit_backups_select) - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = Color.White - ) - } - - // Delete button - IconButton( - enabled = selectionMode && selectedIds.isNotEmpty(), - onClick = { - viewModel.deleteSelected(selectedIds.toList()) { - selectedIds = emptySet() - selectionMode = false - } - }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = stringResource(R.string.edit_backups_delete_selected), - tint = if (selectionMode && selectedIds.isNotEmpty()) - Color(0xFFFF6B6B) - else Color.White.copy(alpha = 0.38f) - ) - } - } - - // ── Segmented toggle (right) — only if backup exists ── - if (hasBackup) { - val selectedSegmentBg = Color.White.copy(alpha = 0.2f) - - Row( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clip(pillShape) - .hazeEffect(state = hazeState, style = hazeStyle) - .background(BlackScrim) - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // "Original" segment - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clip(pillShape) - .background( - if (isShowingOriginal) selectedSegmentBg - else Color.Transparent - ) - .clickable { - if (currentMediaId != -1L && !isShowingOriginal) { - showingOriginalIds = showingOriginalIds + currentMediaId - } - }, - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.edit_backups_viewing_original), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isShowingOriginal) FontWeight.Bold else FontWeight.Medium, - color = if (isShowingOriginal) Color.White else Color.White.copy(alpha = 0.5f), - maxLines = 1 - ) - } - - // "Edited" segment - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clip(pillShape) - .background( - if (!isShowingOriginal) selectedSegmentBg - else Color.Transparent - ) - .clickable { - if (currentMediaId != -1L && isShowingOriginal) { - showingOriginalIds = showingOriginalIds - currentMediaId - } - }, - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.edit_backups_viewing_edited), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (!isShowingOriginal) FontWeight.Bold else FontWeight.Medium, - color = if (!isShowingOriginal) Color.White else Color.White.copy(alpha = 0.5f), - maxLines = 1 - ) - } - } - } - } - } - } - - // ── Actions Bottom Sheet ── - val actionSheetState = rememberAppBottomSheetState() - val scope = rememberCoroutineScope() - - val autoCleanupText = stringResource(R.string.edit_backups_auto_cleanup) - val autoCleanupSummary = stringResource(R.string.edit_backups_auto_cleanup_summary) - val deleteAllText = stringResource(R.string.edit_backups_delete_all) - val deleteAllSummary = stringResource(R.string.edit_backups_delete_all_summary) - val errorContainer = MaterialTheme.colorScheme.errorContainer - val onErrorContainer = MaterialTheme.colorScheme.onErrorContainer - - val actionOptions = remember( - autoCleanupText, autoCleanupSummary, deleteAllText, deleteAllSummary, - errorContainer, onErrorContainer - ) { - mutableStateListOf( - OptionItem( - icon = Icons.Outlined.AutoDelete, - text = autoCleanupText, - summary = autoCleanupSummary, - onClick = { - scope.launch { actionSheetState.hide() } - viewModel.autoCleanup { } - } - ), - OptionItem( - icon = Icons.Outlined.DeleteForever, - text = deleteAllText, - summary = deleteAllSummary, - containerColor = errorContainer, - contentColor = onErrorContainer, - onClick = { - scope.launch { actionSheetState.hide() } - showDeleteDialog = true - } - ) - ) - } - - LaunchedEffect(showActionMenu) { - if (showActionMenu) actionSheetState.show() - } - LaunchedEffect(actionSheetState.isVisible) { - if (!actionSheetState.isVisible) showActionMenu = false - } - - OptionSheet( - state = actionSheetState, - onDismiss = { showActionMenu = false }, - optionList = arrayOf(actionOptions) - ) - - // ── Delete All Dialog ── - if (showDeleteDialog) { - AlertDialog( - onDismissRequest = { showDeleteDialog = false }, - title = { Text(stringResource(R.string.edit_backups_delete_all)) }, - text = { Text(stringResource(R.string.edit_backups_delete_all_confirmation)) }, - confirmButton = { - TextButton( - onClick = { - showDeleteDialog = false - viewModel.deleteAll { - runCatching { - (activity as ComponentActivity).onBackPressedDispatcher.onBackPressed() - }.getOrElse { eventHandler.navigateUpAction() } - } - } - ) { - Text( - stringResource(R.string.action_confirm), - color = MaterialTheme.colorScheme.error - ) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsGeneralScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsGeneralScreen.kt index 7854f1d61c..0395d7f8c8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsGeneralScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsGeneralScreen.kt @@ -1,11 +1,8 @@ package com.dot.gallery.feature_node.presentation.settings.subsettings import androidx.activity.compose.BackHandler -import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,20 +13,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Shield import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList @@ -37,51 +30,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.dot.gallery.R import com.dot.gallery.core.Position import com.dot.gallery.core.Settings -import com.dot.gallery.core.Settings.Misc.rememberAppNameAlias import com.dot.gallery.core.Settings.Misc.rememberTrashConfirmationEnabled import com.dot.gallery.core.SettingsEntity import com.dot.gallery.core.util.SdkCompat import com.dot.gallery.feature_node.presentation.settings.components.BaseSettingsScreen -import com.dot.gallery.feature_node.presentation.settings.components.ChooserPreferenceDetailScreen -import com.dot.gallery.feature_node.presentation.settings.components.PreferenceOption import com.dot.gallery.feature_node.presentation.settings.components.SwitchPreferenceDetailScreen -import com.dot.gallery.feature_node.presentation.settings.components.rememberPreference import com.dot.gallery.feature_node.presentation.settings.components.rememberSwitchPreference -import com.dot.gallery.feature_node.presentation.util.changeAppAlias -import com.dot.gallery.feature_node.presentation.util.restartApplication -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch private const val DETAIL_TRASH = "trash" private const val DETAIL_TRASH_CONFIRM = "trash_confirm" private const val DETAIL_SECURE = "secure" private const val DETAIL_VIBRATIONS = "vibrations" -private const val DETAIL_APP_NAME = "app_name" -private const val DETAIL_VAULT_ENCRYPT = "vault_encrypt" @Composable fun SettingsGeneralScreen() { var detailKey by rememberSaveable { mutableStateOf(null) } - val context = LocalContext.current - val scope = rememberCoroutineScope() var trashCanEnabled by Settings.Misc.rememberTrashEnabled() var trashConfirmationEnabled by rememberTrashConfirmationEnabled() var secureMode by Settings.Misc.rememberSecureMode() var allowVibrations by Settings.Misc.rememberAllowVibrations() - var appNameAlias by rememberAppNameAlias() - var vaultEncryptBehavior by Settings.Vault.rememberVaultEncryptBehavior() when (detailKey) { DETAIL_TRASH -> { @@ -121,39 +95,6 @@ fun SettingsGeneralScreen() { description = stringResource(R.string.allow_vibrations_description), ) } - DETAIL_APP_NAME -> { - BackHandler { detailKey = null } - ChooserPreferenceDetailScreen( - title = stringResource(R.string.change_app_name), - description = stringResource(R.string.app_name_description), - preview = { AppNamePreview(appNameAlias) }, - options = listOf( - PreferenceOption(Settings.Misc.ALIAS_REFRA, Settings.Misc.ALIAS_REFRA, appNameAlias == Settings.Misc.ALIAS_REFRA), - PreferenceOption(Settings.Misc.ALIAS_GALLERY, Settings.Misc.ALIAS_GALLERY, appNameAlias == Settings.Misc.ALIAS_GALLERY), - ), - onOptionSelected = { - appNameAlias = it - context.changeAppAlias(it) - scope.launch { - delay(300) - context.restartApplication() - } - }, - ) - } - DETAIL_VAULT_ENCRYPT -> { - BackHandler { detailKey = null } - ChooserPreferenceDetailScreen( - title = stringResource(R.string.vault_encrypt_behavior), - description = stringResource(R.string.vault_encrypt_behavior_summary), - options = listOf( - PreferenceOption(Settings.Vault.ENCRYPT_ASK, stringResource(R.string.vault_encrypt_ask), vaultEncryptBehavior == Settings.Vault.ENCRYPT_ASK), - PreferenceOption(Settings.Vault.ENCRYPT_DELETE, stringResource(R.string.vault_encrypt_delete), vaultEncryptBehavior == Settings.Vault.ENCRYPT_DELETE), - PreferenceOption(Settings.Vault.ENCRYPT_KEEP, stringResource(R.string.vault_encrypt_keep), vaultEncryptBehavior == Settings.Vault.ENCRYPT_KEEP), - ), - onOptionSelected = { vaultEncryptBehavior = it }, - ) - } else -> { GeneralListScreen( trashCanEnabled = trashCanEnabled, @@ -164,8 +105,6 @@ fun SettingsGeneralScreen() { onSecureChange = { secureMode = it }, allowVibrations = allowVibrations, onVibrationsChange = { allowVibrations = it }, - appNameAlias = appNameAlias, - vaultEncryptBehavior = vaultEncryptBehavior, onDetailClick = { detailKey = it }, ) } @@ -182,8 +121,6 @@ private fun GeneralListScreen( onSecureChange: (Boolean) -> Unit, allowVibrations: Boolean, onVibrationsChange: (Boolean) -> Unit, - appNameAlias: String, - vaultEncryptBehavior: String, onDetailClick: (String) -> Unit, ) { @Composable @@ -235,38 +172,12 @@ private fun GeneralListScreen( isChecked = allowVibrations, onCheck = onVibrationsChange, onClick = { onDetailClick(DETAIL_VIBRATIONS) }, - screenPosition = Position.Middle - ) - - val appNamePref = rememberPreference( - appNameAlias, - title = stringResource(R.string.change_app_name), - summary = stringResource(R.string.change_app_name_summary), - onClick = { onDetailClick(DETAIL_APP_NAME) }, screenPosition = Position.Bottom ) - val vaultSectionPref = remember(res) { - SettingsEntity.Header(title = res.getString(R.string.vault)) - } - - val vaultEncryptBehaviorSummary = when (vaultEncryptBehavior) { - Settings.Vault.ENCRYPT_DELETE -> stringResource(R.string.vault_encrypt_delete) - Settings.Vault.ENCRYPT_KEEP -> stringResource(R.string.vault_encrypt_keep) - else -> stringResource(R.string.vault_encrypt_ask) - } - val vaultEncryptPref = rememberPreference( - vaultEncryptBehavior, - title = stringResource(R.string.vault_encrypt_behavior), - summary = vaultEncryptBehaviorSummary, - onClick = { onDetailClick(DETAIL_VAULT_ENCRYPT) }, - screenPosition = Position.Alone - ) - return remember( trashCanEnabledPref, trashConfirmationEnabledPref, - secureModePref, allowVibrationsPref, appNamePref, - vaultEncryptPref + secureModePref, allowVibrationsPref ) { mutableStateListOf().apply { if (SdkCompat.supportsTrash) { @@ -277,9 +188,6 @@ private fun GeneralListScreen( add(otherSectionPref) add(secureModePref) add(allowVibrationsPref) - add(appNamePref) - add(vaultSectionPref) - add(vaultEncryptPref) } } } @@ -340,55 +248,3 @@ private fun SecureModePreview(isChecked: Boolean) { } } } - -@Composable -private fun AppNamePreview(currentAlias: String) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - listOf(Settings.Misc.ALIAS_REFRA, Settings.Misc.ALIAS_GALLERY).forEach { alias -> - val selected = currentAlias == alias - val borderColor = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant - val containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - - Column( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .border(width = 2.dp, color = borderColor, shape = RoundedCornerShape(16.dp)) - .background(containerColor) - .padding(horizontal = 24.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Image( - painter = rememberDrawablePainter( - drawable = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher_round) - ), - contentDescription = alias, - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - ) - Text( - text = alias, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center - ) - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(24.dp) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsMediaViewerScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsMediaViewerScreen.kt index 701c8c237c..34ba483692 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsMediaViewerScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsMediaViewerScreen.kt @@ -7,13 +7,13 @@ package com.dot.gallery.feature_node.presentation.settings.subsettings import androidx.activity.compose.BackHandler import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,10 +31,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.MoreVert @@ -60,8 +56,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -75,15 +71,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap import com.composeunstyled.LocalTextStyle import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.Position import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.Settings.Misc.rememberDateHeaderFormat import com.dot.gallery.core.Settings.Misc.rememberAudioFocus import com.dot.gallery.core.Settings.Misc.rememberAutoHideOnVideoPlay +import com.dot.gallery.core.Settings.Misc.rememberDateHeaderFormat import com.dot.gallery.core.Settings.Misc.rememberDefaultImageEditor import com.dot.gallery.core.Settings.Misc.rememberFullBrightnessView import com.dot.gallery.core.Settings.Misc.rememberShowFavoriteButton @@ -97,11 +94,14 @@ import com.dot.gallery.feature_node.presentation.settings.components.PreferenceO import com.dot.gallery.feature_node.presentation.settings.components.SwitchPreferenceDetailScreen import com.dot.gallery.feature_node.presentation.settings.components.rememberPreference import com.dot.gallery.feature_node.presentation.settings.components.rememberSwitchPreference +import com.dot.gallery.feature_node.presentation.util.LocalHazeState import com.dot.gallery.feature_node.presentation.util.getDate import com.dot.gallery.feature_node.presentation.util.getEditImageCapableApps import com.dot.gallery.feature_node.presentation.util.restartApplication import com.google.accompanist.drawablepainter.rememberDrawablePainter -import androidx.core.graphics.drawable.toBitmap +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsNavigationScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsNavigationScreen.kt index 269ca9747b..7d363bfd75 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsNavigationScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsNavigationScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dot.gallery.R +import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.Position import com.dot.gallery.core.Settings.Misc.rememberAutoHideNavBar import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar @@ -57,6 +58,7 @@ import com.dot.gallery.core.Settings.Misc.rememberOldNavbar import com.dot.gallery.core.Settings.Misc.rememberSelectionSheetConfig import com.dot.gallery.core.Settings.Misc.rememberShowSelectionTitles import com.dot.gallery.core.SettingsEntity +import com.dot.gallery.core.navigate import com.dot.gallery.feature_node.presentation.settings.components.BaseSettingsScreen import com.dot.gallery.feature_node.presentation.settings.components.ChooserPreferenceDetailScreen import com.dot.gallery.feature_node.presentation.settings.components.PreferenceOption @@ -64,8 +66,6 @@ import com.dot.gallery.feature_node.presentation.settings.components.SwitchPrefe import com.dot.gallery.feature_node.presentation.settings.components.rememberPreference import com.dot.gallery.feature_node.presentation.settings.components.rememberSwitchPreference import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.navigate import com.dot.gallery.ui.core.Icons as AppIcons private const val DETAIL_LAUNCH_SCREEN = "launch_screen" diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSecurityScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSecurityScreen.kt new file mode 100644 index 0000000000..ad238dd9f7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSecurityScreen.kt @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.settings.subsettings + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.res.stringResource +import com.dot.gallery.R +import com.dot.gallery.core.Position +import com.dot.gallery.core.Settings +import com.dot.gallery.core.SettingsEntity +import com.dot.gallery.feature_node.presentation.settings.components.BaseSettingsScreen +import com.dot.gallery.feature_node.presentation.settings.components.ChooserPreferenceDetailScreen +import com.dot.gallery.feature_node.presentation.settings.components.PreferenceOption +import com.dot.gallery.feature_node.presentation.settings.components.rememberPreference + +private const val DETAIL_METADATA_ISOLATION = "metadata_isolation" + +@Composable +fun SettingsSecurityScreen() { + var detailKey by rememberSaveable { mutableStateOf(null) } + var metadataIsolationMode by Settings.Security.rememberMetadataIsolationMode() + + when (detailKey) { + DETAIL_METADATA_ISOLATION -> { + BackHandler { + detailKey = null + } + ChooserPreferenceDetailScreen( + title = stringResource(id = R.string.security_metadata_isolation), + description = stringResource(id = R.string.security_metadata_isolation_summary), + options = listOf( + PreferenceOption( + value = Settings.Security.METADATA_ISOLATION_SHARED, + label = stringResource(id = R.string.security_metadata_isolation_shared), + isSelected = metadataIsolationMode == Settings.Security.METADATA_ISOLATION_SHARED, + summary = stringResource(id = R.string.security_metadata_isolation_shared_summary), + ), + PreferenceOption( + value = Settings.Security.METADATA_ISOLATION_HYBRID, + label = stringResource(id = R.string.security_metadata_isolation_hybrid), + isSelected = metadataIsolationMode == Settings.Security.METADATA_ISOLATION_HYBRID, + summary = stringResource(id = R.string.security_metadata_isolation_hybrid_summary), + ), + PreferenceOption( + value = Settings.Security.METADATA_ISOLATION_PER_FILE, + label = stringResource(id = R.string.security_metadata_isolation_per_file), + isSelected = metadataIsolationMode == Settings.Security.METADATA_ISOLATION_PER_FILE, + summary = stringResource(id = R.string.security_metadata_isolation_per_file_summary), + ), + ), + onOptionSelected = { + metadataIsolationMode = it + }, + ) + } + + else -> { + SecurityListScreen( + metadataIsolationMode = metadataIsolationMode, + onDetailClick = { + detailKey = it + }, + ) + } + } +} + +@Composable +private fun SecurityListScreen( + metadataIsolationMode: String, + onDetailClick: (String) -> Unit, +) { + @Composable + fun settings(): SnapshotStateList { + val metadataIsolationSummary = when (metadataIsolationMode) { + Settings.Security.METADATA_ISOLATION_SHARED -> { + stringResource(id = R.string.security_metadata_isolation_shared) + } + + Settings.Security.METADATA_ISOLATION_PER_FILE -> { + stringResource(id = R.string.security_metadata_isolation_per_file) + } + + else -> { + stringResource(id = R.string.security_metadata_isolation_hybrid) + } + } + val metadataIsolationPref = rememberPreference( + metadataIsolationMode, + title = stringResource(id = R.string.security_metadata_isolation), + summary = metadataIsolationSummary, + onClick = { + onDetailClick(DETAIL_METADATA_ISOLATION) + }, + screenPosition = Position.Alone, + ) + + return remember(metadataIsolationPref) { + mutableStateListOf(metadataIsolationPref) + } + } + + BaseSettingsScreen( + title = stringResource(id = R.string.settings_security), + settingsList = settings(), + ) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSelectionActionsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSelectionActionsScreen.kt index 271cf9696e..cb1644ddb6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSelectionActionsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSelectionActionsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,7 +30,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -89,15 +89,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.dot.gallery.R import com.dot.gallery.core.Position -import com.dot.gallery.core.SettingsEntity import com.dot.gallery.core.Settings.Misc.rememberSelectionSheetConfig import com.dot.gallery.core.Settings.Misc.rememberShowSelectionTitles +import com.dot.gallery.core.SettingsEntity import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem import com.dot.gallery.feature_node.domain.model.ActionZone import com.dot.gallery.feature_node.domain.model.SelectionAction import com.dot.gallery.feature_node.domain.model.SelectionSheetConfig import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState +import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSmartFeaturesScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSmartFeaturesScreen.kt index 138834a1b7..486dd03348 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSmartFeaturesScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsSmartFeaturesScreen.kt @@ -32,13 +32,16 @@ import com.dot.gallery.feature_node.presentation.util.Screen @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsSmartFeaturesScreen( - viewModel: SmartFeaturesViewModel = hiltViewModel() + viewModel: SmartFeaturesViewModel = hiltViewModel(), ) { val handler = LocalEventHandler.current - val hasInternet = viewModel.hasInternetPermission val modelStatus by viewModel.modelStatus.collectAsStateWithLifecycle() val isMetadataWorkerRunning by viewModel.isMetadataWorkerRunning.collectAsStateWithLifecycle() val metadataProgress by viewModel.metadataProgress.collectAsStateWithLifecycle() + val analysisSettings by viewModel.analysisSettings.collectAsStateWithLifecycle() + val analysisProgress by viewModel.analysisProgress.collectAsStateWithLifecycle() + val isAnalysisRunning by viewModel.isAnalysisRunning.collectAsStateWithLifecycle() + val isUpdatingAnalysis by viewModel.isUpdatingAnalysis.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -51,28 +54,30 @@ fun SettingsSmartFeaturesScreen( navigationIcon = { NavigationBackButton() }, scrollBehavior = scrollBehavior, colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface - ) + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), ) } ) { padding -> // Resolve strings outside the non-composable settings{} DSL val smartFeaturesHeader = stringResource(R.string.ai_category) - val aiModelsManagerTitle = if (hasInternet) stringResource(R.string.ai_models_manager) else "" - val modelSummary = if (hasInternet) when (modelStatus) { - ModelStatus.READY -> stringResource(R.string.ai_models_ready_summary) - ModelStatus.NOT_INSTALLED -> stringResource(R.string.ai_models_download_summary) - ModelStatus.DOWNLOADING, ModelStatus.COPYING -> stringResource(R.string.ai_models_downloading) - ModelStatus.ERROR -> stringResource(R.string.ai_models_error) - } else "" - val categoriesTitle = if (hasInternet) stringResource(R.string.categories) else "" - val categoriesSummary = if (hasInternet) { - if (modelStatus == ModelStatus.READY) { - stringResource(R.string.categorise_your_media) - } else { - stringResource(R.string.ai_models_unavailable) - } - } else "" + val analysisTitle = stringResource(R.string.ai_media_analysis) + val analysisSummary = when { + isUpdatingAnalysis -> stringResource(R.string.ai_media_analysis_updating) + isAnalysisRunning && analysisProgress != null -> stringResource( + R.string.ai_media_analysis_progress, + requireNotNull(analysisProgress).toInt(), + ) + isAnalysisRunning -> stringResource(R.string.ai_media_analysis_running) + analysisSettings.analysisEnabled -> stringResource(R.string.ai_media_analysis_enabled_summary) + else -> stringResource(R.string.ai_media_analysis_disabled_summary) + } + val categoriesTitle = stringResource(R.string.categories) + val categoriesSummary = when (modelStatus) { + ModelStatus.CHECKING -> stringResource(R.string.ai_models_checking) + ModelStatus.READY -> stringResource(R.string.categorise_your_media) + ModelStatus.ERROR -> stringResource(R.string.ai_models_unavailable) + } val databaseHeader = stringResource(R.string.database) val refreshMetadataTitle = stringResource(R.string.refresh_metadata) val metadataSummary = when { @@ -83,36 +88,36 @@ fun SettingsSmartFeaturesScreen( else -> stringResource(R.string.metadata_idle) } - val storageHeader = stringResource(R.string.edit_backups_storage) - val editBackupsTitle = stringResource(R.string.edit_backups) - val editBackupsSummary = stringResource(R.string.edit_backups_summary) - LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = padding.calculateStartPadding(LocalLayoutDirection.current), end = padding.calculateEndPadding(LocalLayoutDirection.current), top = 16.dp + padding.calculateTopPadding(), - bottom = padding.calculateBottomPadding() - ) + bottom = padding.calculateBottomPadding(), + ), ) { settings { - if (hasInternet) { - Header(smartFeaturesHeader) + Header(smartFeaturesHeader) - Preference( - title = aiModelsManagerTitle, - summary = modelSummary, - onClick = { handler.navigate(Screen.AIModelsManagerScreen()) } - ) + SwitchPreference( + title = analysisTitle, + summary = analysisSummary, + enabled = !isUpdatingAnalysis && ( + analysisSettings.analysisEnabled || modelStatus == ModelStatus.READY + ), + isChecked = analysisSettings.analysisEnabled, + onCheck = viewModel::setAnalysisEnabled, + ) - Preference( - title = categoriesTitle, - summary = categoriesSummary, - enabled = modelStatus == ModelStatus.READY, - onClick = { handler.navigate(Screen.CategoriesScreen()) } - ) - } + Preference( + title = categoriesTitle, + summary = categoriesSummary, + enabled = modelStatus == ModelStatus.READY && + analysisSettings.analysisEnabled && + !isUpdatingAnalysis, + onClick = { handler.navigate(Screen.CategoriesScreen()) }, + ) Header(databaseHeader) @@ -120,15 +125,7 @@ fun SettingsSmartFeaturesScreen( title = refreshMetadataTitle, summary = metadataSummary, enabled = !isMetadataWorkerRunning, - onClick = { viewModel.refreshMetadata() } - ) - - Header(storageHeader) - - Preference( - title = editBackupsTitle, - summary = editBackupsSummary, - onClick = { handler.navigate(Screen.EditBackupsViewerScreen()) } + onClick = { viewModel.refreshMetadata() }, ) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsTimelineAlbumsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsTimelineAlbumsScreen.kt index c88b9ddff2..96d1301425 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsTimelineAlbumsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SettingsTimelineAlbumsScreen.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -62,12 +62,12 @@ import com.dot.gallery.core.Settings.Misc.rememberGroupRawJpg import com.dot.gallery.core.Settings.Misc.rememberGroupSimilarMedia import com.dot.gallery.core.Settings.Misc.rememberTimelineLayoutType import com.dot.gallery.core.SettingsEntity -import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem import com.dot.gallery.core.navigate import com.dot.gallery.core.util.SdkCompat import com.dot.gallery.feature_node.presentation.settings.components.BaseSettingsScreen import com.dot.gallery.feature_node.presentation.settings.components.ChooserPreferenceDetailScreen import com.dot.gallery.feature_node.presentation.settings.components.PreferenceOption +import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem import com.dot.gallery.feature_node.presentation.settings.components.SwitchPreferenceDetailScreen import com.dot.gallery.feature_node.presentation.settings.components.rememberPreference import com.dot.gallery.feature_node.presentation.settings.components.rememberSwitchPreference diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SmartFeaturesViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SmartFeaturesViewModel.kt index 13c6df54a3..85da285060 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SmartFeaturesViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/subsettings/SmartFeaturesViewModel.kt @@ -10,79 +10,92 @@ import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery -import com.dot.gallery.core.ml.DownloadInfo -import com.dot.gallery.core.ml.ModelFileInfo import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.core.ml.ModelStatus -import com.dot.gallery.core.workers.cancelModelDownload -import com.dot.gallery.core.workers.downloadModels import com.dot.gallery.core.workers.forceMetadataCollect +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSettings import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel -class SmartFeaturesViewModel @Inject constructor( +class SmartFeaturesViewModel @Inject internal constructor( private val modelManager: ModelManager, - private val workManager: WorkManager + private val workManager: WorkManager, + private val aiMediaAnalysis: AiMediaAnalysis, ) : ViewModel() { - val modelStatus: StateFlow = modelManager.status - val downloadProgress: StateFlow = modelManager.downloadProgress - val errorMessage: StateFlow = modelManager.errorMessage + internal val analysisSettings: StateFlow = aiMediaAnalysis.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = AiMediaAnalysisSettings( + analysisEnabled = false, + categoryClassificationEnabled = true, + ), + ) - val downloadInfo: StateFlow = modelManager.downloadInfo + private val _isUpdatingAnalysis = MutableStateFlow(false) + val isUpdatingAnalysis = _isUpdatingAnalysis.asStateFlow() - val installedSize: Long get() = modelManager.getInstalledSize() + val analysisProgress: StateFlow = aiMediaAnalysis.workState.map { workState -> + workState.analysisProgress + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null, + ) - fun getFileInfos(): List = modelManager.getFileInfos() + val isAnalysisRunning: StateFlow = aiMediaAnalysis.workState.map { workState -> + workState.isAnalysisActive + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) - val hasInternetPermission: Boolean get() = modelManager.hasInternetPermission + val modelStatus: StateFlow = modelManager.status val isMetadataWorkerRunning: StateFlow = workManager.getWorkInfosFlow( - WorkQuery.fromUniqueWorkNames("MetadataCollection") + WorkQuery.fromUniqueWorkNames("MetadataCollection"), ).map { infos -> infos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = false + initialValue = false, ) val metadataProgress: StateFlow = workManager.getWorkInfosFlow( - WorkQuery.fromUniqueWorkNames("MetadataCollection") + WorkQuery.fromUniqueWorkNames("MetadataCollection"), ).map { infos -> infos.firstOrNull { it.state == WorkInfo.State.RUNNING } ?.progress?.getInt("progress", -1) ?: -1 }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = -1 + initialValue = -1, ) - fun downloadModels() { - if (!modelManager.hasInternetPermission) return - workManager.downloadModels() - } - - fun cancelDownload() { - workManager.cancelModelDownload() - viewModelScope.launch { - modelManager.deleteModels() - } + fun refreshMetadata() { + workManager.forceMetadataCollect() } - fun deleteModels() { - viewModelScope.launch { - modelManager.deleteModels() + fun setAnalysisEnabled(enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + _isUpdatingAnalysis.value = true + try { + aiMediaAnalysis.setAnalysisEnabled(enabled = enabled) + } finally { + _isUpdatingAnalysis.value = false + } } } - - fun refreshMetadata() { - workManager.forceMetadataCollect() - } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt index 852e4f3779..95636e6e7c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt @@ -5,20 +5,16 @@ import android.app.Activity import android.content.Context import android.content.pm.PackageManager import android.os.Build -import android.os.Environment import android.provider.MediaStore import android.widget.Toast import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.PermMedia -import androidx.compose.material.icons.rounded.SignalWifi4Bar import androidx.compose.material.icons.rounded.VideoFile -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,7 +32,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -44,12 +39,11 @@ import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings.Misc.rememberIsMediaManager +import com.dot.gallery.core.presentation.components.SetupButton import com.dot.gallery.core.presentation.components.SetupWizard import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionLayout import com.dot.gallery.feature_node.presentation.util.RepeatOnResume -import com.dot.gallery.feature_node.presentation.util.isManageFilesAllowed -import com.dot.gallery.feature_node.presentation.util.launchManageFiles import com.dot.gallery.feature_node.presentation.util.launchManageMedia import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -82,7 +76,6 @@ fun SetupScreen( } SetupWizard( - painter = painterResource(R.drawable.ic_launcher_foreground_monochrome), title = stringResource(id = R.string.welcome), subtitle = appName, contentPadding = 0.dp, @@ -139,9 +132,7 @@ fun SetupScreen( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { var useMediaManager by rememberIsMediaManager() - var isStorageManager by remember { mutableStateOf(Environment.isExternalStorageManager()) } RepeatOnResume { - isStorageManager = Environment.isExternalStorageManager() useMediaManager = MediaStore.canManageMedia(context) } @@ -154,7 +145,7 @@ fun SetupScreen( val grantedString = stringResource(R.string.granted) val secondaryContainer = MaterialTheme.colorScheme.secondaryContainer val onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer - val optionsList = remember(useMediaManager, isStorageManager) { + val optionsList = remember(useMediaManager) { mutableStateListOf( OptionItem( icon = Icons.Rounded.PermMedia, @@ -168,21 +159,6 @@ fun SetupScreen( }, containerColor = secondaryContainer, contentColor = onSecondaryContainer - ), - OptionItem( - icon = Icons.Rounded.FileOpen, - text = resources.getString(R.string.permission_manage_files_title), - summary = if (!isStorageManager && isManageFilesAllowed) resources.getString( - R.string.permission_manage_files_summary - ) else grantedString, - enabled = !isStorageManager && isManageFilesAllowed, - onClick = { - scope.launch { - context.launchManageFiles() - } - }, - containerColor = secondaryContainer, - contentColor = onSecondaryContainer ) ) } @@ -201,7 +177,7 @@ fun SetupScreen( onPermissionResult = { isGranted = it } ) LaunchedEffect( - useMediaManager, isStorageManager, isGranted + useMediaManager, isGranted ) { optionsList.removeIf { item -> item.icon == Icons.Rounded.Notifications } optionsList.add( @@ -253,14 +229,5 @@ private val Context.requiredPermissionsList: Array() - intent.data?.let(uriList::add) - if (clipData != null) { - for (i in 0 until clipData.itemCount) { - uriList.add(clipData.getItemAt(i).uri) - } - } - setShowWhenLocked(isSecure) + val isReview = isReviewAction(reviewIntent = intent) + val uriList = getReviewUris(reviewIntent = intent) setContent { GalleryTheme { val allowBlur by rememberAllowBlur() @@ -83,8 +82,9 @@ class StandaloneActivity : ComponentActivity() { val viewModel = hiltViewModel { factory -> factory.create( - reviewMode = action.contains("REVIEW", true), - dataList = uriList.toList() + reviewMode = isReview, + secureReviewMode = false, + dataList = uriList, ) } CompositionLocalProvider( @@ -110,7 +110,6 @@ class StandaloneActivity : ComponentActivity() { mediaSelector = mediaSelector ) { Scaffold { paddingValues -> - val vaults = viewModel.vaults.collectAsStateWithLifecycle() val mediaState = viewModel.mediaState.collectAsStateWithLifecycle() val albumsState = viewModel.albumsState.collectAsStateWithLifecycle() val metadataState = @@ -127,9 +126,9 @@ class StandaloneActivity : ComponentActivity() { toggleRotate = ::toggleOrientation, paddingValues = paddingValues, isStandalone = true, + isSecureReview = false, mediaId = mediaId, mediaState = mediaState, - vaultState = vaults, albumsState = albumsState, metadataState = metadataState, sharedTransitionScope = this@SharedTransitionLayout, @@ -148,4 +147,32 @@ class StandaloneActivity : ComponentActivity() { } } -} \ No newline at end of file + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (intent.action == MediaStore.ACTION_REVIEW_SECURE) { + finish() + return + } + setIntent(intent) + viewModelStore.clear() + recreate() + } + + private fun isReviewAction(reviewIntent: Intent): Boolean { + return reviewIntent.action == MediaStore.ACTION_REVIEW || + reviewIntent.action == CAMERA_ACTION_REVIEW + } + + private fun getReviewUris(reviewIntent: Intent): List { + val uriList = linkedSetOf() + reviewIntent.data?.let(uriList::add) + reviewIntent.clipData?.let { clipData -> + for (i in 0 until clipData.itemCount) { + clipData.getItemAt(i).uri?.let(uriList::add) + } + } + + return uriList.toList() + } + +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt index 92590a826c..6c4b955565 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt @@ -11,18 +11,11 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.MediaDistributor +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.UriMedia import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.getUri -import androidx.work.WorkManager -import com.dot.gallery.core.workers.VaultOperationWorker -import com.dot.gallery.core.workers.enqueueVaultOperation import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -35,7 +28,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch @Suppress("UNCHECKED_CAST") @HiltViewModel(assistedFactory = StandaloneViewModel.Factory::class) @@ -43,17 +35,20 @@ class StandaloneViewModel @AssistedInject constructor( @param:ApplicationContext private val applicationContext: Context, private val repository: MediaRepository, - private val workManager: WorkManager, distributor: MediaDistributor, - @Assisted private val reviewMode: Boolean, - @Assisted private val dataList: List + @Assisted("reviewMode") private val reviewMode: Boolean, + @Assisted("secureReviewMode") private val secureReviewMode: Boolean, + @Assisted private val dataList: List, ) : ViewModel() { @AssistedFactory interface Factory { fun create( + @Assisted("reviewMode") reviewMode: Boolean, - dataList: List + @Assisted("secureReviewMode") + secureReviewMode: Boolean, + dataList: List, ): StandaloneViewModel } @@ -64,7 +59,11 @@ class StandaloneViewModel @AssistedInject constructor( try { ContentUris.parseId(uri) } catch (_: NumberFormatException) { null } } ?: -1L - var mediaState = repository.getMediaListByUris(dataList, reviewMode) + var mediaState = repository.getMediaListByUris( + listOfUris = dataList, + reviewMode = reviewMode, + onlyMatching = secureReviewMode, + ) .map { val data = it.data if (data != null) { @@ -81,29 +80,17 @@ class StandaloneViewModel @AssistedInject constructor( .stateIn(viewModelScope, SharingStarted.Eagerly, MediaState()) - val vaults = distributor.vaultsMediaFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, VaultState()) - val albumsState = distributor.albumsFlow .stateIn(viewModelScope, SharingStarted.Eagerly, AlbumState()) val metadataState = distributor.metadataFlow .stateIn(viewModelScope, SharingStarted.Eagerly, MediaMetadataState()) - - fun addMedia(vault: Vault, media: UriMedia) { - workManager.enqueueVaultOperation( - operation = VaultOperationWorker.OP_ENCRYPT, - media = listOf(media.getUri()), - vault = vault - ) - } - - private fun mediaFromUris(): MediaState { + private fun mediaFromUris(): MediaState { val mediaList = dataList.mapNotNull { Media.createFromUri(applicationContext, it) as T? } return MediaState(media = mediaList, isLoading = false) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt index acbf4ad387..96d6fde15b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -37,6 +37,7 @@ import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionLayout import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.launchViewUri import kotlinx.coroutines.launch @Suppress("DEPRECATION") @@ -46,23 +47,23 @@ fun SupportSheet( state: AppBottomSheetState ) { val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current + val context = LocalContext.current var showCryptoOptions by rememberSaveable { mutableStateOf(false) } val clipboard = LocalClipboardManager.current - val mainOptions = remember { + val mainOptions = remember(context) { listOf( OptionItem( text = "PayPal", onClick = { - uriHandler.openUri("https://www.paypal.com/paypalme/iacobionut01") + context.launchViewUri("https://www.paypal.com/paypalme/iacobionut01") } ), OptionItem( text = "Revolut", onClick = { - uriHandler.openUri("https://revolut.me/somaldoaca") + context.launchViewUri("https://revolut.me/somaldoaca") } ), OptionItem( @@ -169,4 +170,4 @@ fun SupportSheet( } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/thumbnail/ThumbnailChangerViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/thumbnail/ThumbnailChangerViewModel.kt index a83c73efa1..8600cc676e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/thumbnail/ThumbnailChangerViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/thumbnail/ThumbnailChangerViewModel.kt @@ -3,12 +3,12 @@ package com.dot.gallery.feature_node.presentation.thumbnail import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.data.repository.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ThumbnailChangerViewModel @Inject constructor( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt index 2ad47afd8d..13bbf4d021 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt @@ -39,9 +39,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -50,38 +50,35 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.LocalEventHandler import com.dot.gallery.core.LocalMediaDistributor import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.BuildConfig import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar import com.dot.gallery.core.Settings.Misc.rememberGridSize -import com.dot.gallery.core.Settings.Misc.rememberLastSeenVersion import com.dot.gallery.core.Settings.Misc.rememberMosaicGridSize import com.dot.gallery.core.Settings.Misc.rememberTimelineLayoutType import com.dot.gallery.core.navigate import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.SelectionSheet import com.dot.gallery.core.toggleNavigationBar -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.isHeaderKey +import com.dot.gallery.feature_node.data.model.isIgnoredKey import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.isHeaderKey -import com.dot.gallery.feature_node.domain.model.isIgnoredKey +import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.MosaicMediaGrid import com.dot.gallery.feature_node.presentation.common.components.MosaicPinchZoomLayout import com.dot.gallery.feature_node.presentation.common.components.StickyHeaderGrid import com.dot.gallery.feature_node.presentation.common.components.TimelineScroller +import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.rememberMosaicPinchZoomState import com.dot.gallery.feature_node.presentation.common.components.rememberStickyHeaderItem -import com.dot.gallery.feature_node.presentation.help.components.WhatsNewHeroCard import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.timeline.components.TimelineNavActions @@ -114,26 +111,6 @@ fun TimelineScreen( val distributor = LocalMediaDistributor.current val isRefreshing by distributor.isRefreshing.collectAsStateWithLifecycle() val refreshScope = rememberCoroutineScope() - var lastSeenVersion by rememberLastSeenVersion() - val showWhatsNew = remember(lastSeenVersion) { lastSeenVersion != BuildConfig.VERSION_NAME } - val whatsNewContent: @Composable (() -> Unit)? = if (showWhatsNew) { - { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - WhatsNewHeroCard( - versionName = BuildConfig.VERSION_NAME, - onClick = { - lastSeenVersion = BuildConfig.VERSION_NAME - eventHandler.navigate(Screen.WhatsNewScreen()) - } - ) - } - } - } else null val selector = LocalMediaSelector.current val selectionState = selector.isSelectionActive.collectAsStateWithLifecycle() val selectedMedia = selector.selectedMedia.collectAsStateWithLifecycle() @@ -298,7 +275,7 @@ fun TimelineScreen( allowSelection = true, canScroll = !mosaicPinchState.isZooming, allowHeaders = true, - aboveGridContent = whatsNewContent, + aboveGridContent = null, isScrolling = isScrolling, emptyContent = { EmptyMedia() }, sharedTransitionScope = sharedTransitionScope, @@ -333,7 +310,6 @@ fun TimelineScreen( canScroll = canScroll, enableStickyHeaders = true, showMonthlyHeader = true, - aboveGridContent = whatsNewContent, isScrolling = isScrolling, emptyContent = { EmptyMedia() }, sharedTransitionScope = sharedTransitionScope, @@ -356,4 +332,4 @@ fun TimelineScreen( selectedMedia = selectedMediaList ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt index 8f96cbf34f..80612d7794 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.State import androidx.compose.ui.res.stringResource import com.dot.gallery.R import com.dot.gallery.core.Constants.Target.TARGET_TRASH -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.common.MediaScreen diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt index 5870fb6a6f..a4f504af7b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ErrorOutline -import com.dot.gallery.core.presentation.components.SetupButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -50,6 +49,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -63,8 +63,9 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Settings.Misc.rememberTrashConfirmationEnabled import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri +import com.dot.gallery.core.presentation.components.SetupButton +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.DELETE import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.RESTORE import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.TRASH @@ -73,6 +74,7 @@ import com.dot.gallery.feature_node.presentation.util.GlideInvalidation import com.dot.gallery.feature_node.presentation.util.canBeTrashed import com.dot.gallery.feature_node.presentation.util.mediaPair import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.toGlideModel import com.dot.gallery.ui.theme.Shapes import kotlinx.coroutines.launch @@ -166,7 +168,13 @@ fun TrashDialog( letterSpacing = MaterialTheme.typography.bodyMedium.letterSpacing ) ) { - append(stringResource(R.string.s_items, dataCopy.size)) + append( + pluralStringResource( + id = R.plurals.item_count, + count = dataCopy.size, + dataCopy.size + ) + ) } }, textAlign = TextAlign.Center, @@ -303,7 +311,7 @@ fun TrashDialog( ) { GlideImage( modifier = Modifier.fillMaxSize(), - model = it.getUri(), + model = it.toGlideModel(), contentDescription = it.label, contentScale = ContentScale.Crop, requestBuilderTransform = { builder -> @@ -361,4 +369,4 @@ fun TrashDialog( enum class TrashDialogAction { TRASH, DELETE, RESTORE -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt index 2c230ecc38..561a797ec4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt @@ -23,7 +23,7 @@ import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.LocalMediaHandler import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Media import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberIsMediaManager diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt index 2603a213a3..7793d637af 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt @@ -7,6 +7,7 @@ package com.dot.gallery.feature_node.presentation.util import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo @@ -52,6 +53,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import com.dot.gallery.BuildConfig @@ -59,9 +61,9 @@ import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.allowVibrations import com.dot.gallery.core.Settings.Misc.rememberFullBrightnessView import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isImage +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri +import com.dot.gallery.feature_node.data.util.isImage import com.dot.gallery.feature_node.presentation.edit.EditActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -292,6 +294,33 @@ private fun Context.isTouchExplorationEnabled(): Boolean { return accessibilityManager?.isTouchExplorationEnabled ?: false } +fun Context.tryStartActivity( + intent: Intent, + errorMessage: String, + showError: Boolean = true, +): Boolean { + return try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + if (showError) { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + false + } +} + +fun Context.launchViewUri(uri: String): Boolean { + val intent = Intent( + Intent.ACTION_VIEW, + uri.toUri(), + ) + return tryStartActivity( + intent = intent, + errorMessage = getString(R.string.error_toast), + ) +} + fun Activity.toggleOrientation() { requestedOrientation = if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED || @@ -302,24 +331,15 @@ fun Activity.toggleOrientation() { } @RequiresApi(Build.VERSION_CODES.S) -fun Context.launchManageMedia() { +fun Context.launchManageMedia(): Boolean { val intent = Intent().apply { action = Settings.ACTION_REQUEST_MANAGE_MEDIA data = Uri.fromParts("package", packageName, null) } - startActivity(intent) -} - -val isManageFilesAllowed: Boolean - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && BuildConfig.ALLOW_ALL_FILES_ACCESS - -@RequiresApi(Build.VERSION_CODES.S) -fun Context.launchManageFiles() { - val intent = Intent().apply { - action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION - data = Uri.fromParts("package", packageName, null) - } - startActivity(intent) + return tryStartActivity( + intent = intent, + errorMessage = getString(R.string.error_toast), + ) } fun Context.getEditImageCapableApps(): List { @@ -330,7 +350,11 @@ fun Context.getEditImageCapableApps(): List { return resolveInfoList.filterNot { it.activityInfo.packageName == BuildConfig.APPLICATION_ID } } -fun Context.launchEditImageIntent(packageName: String, uri: Uri) { +fun Context.launchEditImageIntent( + packageName: String, + uri: Uri, + showError: Boolean = true, +): Boolean { val intent = Intent(Intent.ACTION_EDIT).apply { addCategory(Intent.CATEGORY_DEFAULT) setDataAndType(uri.authorizedUri(this@launchEditImageIntent), "image/*") @@ -338,12 +362,17 @@ fun Context.launchEditImageIntent(packageName: String, uri: Uri) { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setPackage(packageName) } - startActivity(intent) + return tryStartActivity( + intent = intent, + errorMessage = getString(R.string.error_toast), + showError = showError, + ) } -fun Context.launchEditIntent(media: T) { +fun Context.launchEditIntent(media: T): Boolean { if (media.isImage) { EditActivity.launchEditor(this@launchEditIntent, media.getUri().authorizedUri(this@launchEditIntent)) + return true } else { val intent = Intent(Intent.ACTION_EDIT).apply { addCategory(Intent.CATEGORY_DEFAULT) @@ -351,31 +380,42 @@ fun Context.launchEditIntent(media: T) { putExtra("mimeType", media.mimeType) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - startActivity(Intent.createChooser(intent, getString(R.string.edit))) + return tryStartActivity( + intent = Intent.createChooser(intent, getString(R.string.edit)), + errorMessage = getString(R.string.error_toast), + ) } } -suspend fun Context.launchUseAsIntent(media: T) = - withContext(Dispatchers.Default) { - val intent = Intent(Intent.ACTION_ATTACH_DATA).apply { - addCategory(Intent.CATEGORY_DEFAULT) - setDataAndType(media.getUri().authorizedUri(this@launchUseAsIntent), media.mimeType) - putExtra("mimeType", media.mimeType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(intent, getString(R.string.set_as))) +suspend fun Context.launchUseAsIntent(media: T): Boolean { + val intent = Intent(Intent.ACTION_ATTACH_DATA).apply { + addCategory(Intent.CATEGORY_DEFAULT) + setDataAndType(media.getUri().authorizedUri(this@launchUseAsIntent), media.mimeType) + putExtra("mimeType", media.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return withContext(Dispatchers.Main) { + tryStartActivity( + intent = Intent.createChooser(intent, getString(R.string.set_as)), + errorMessage = getString(R.string.error_toast), + ) } +} -suspend fun Context.launchOpenWithIntent(media: T) = - withContext(Dispatchers.Default) { - val intent = Intent(Intent.ACTION_VIEW).apply { - addCategory(Intent.CATEGORY_DEFAULT) - setDataAndType(media.getUri().authorizedUri(this@launchOpenWithIntent), media.mimeType) - putExtra("mimeType", media.mimeType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(intent, getString(R.string.open_with))) +suspend fun Context.launchOpenWithIntent(media: T): Boolean { + val intent = Intent(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + setDataAndType(media.getUri().authorizedUri(this@launchOpenWithIntent), media.mimeType) + putExtra("mimeType", media.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + return withContext(Dispatchers.Main) { + tryStartActivity( + intent = Intent.createChooser(intent, getString(R.string.open_with)), + errorMessage = getString(R.string.error_toast), + ) + } +} @Composable fun rememberIsMediaManager(): Boolean { @@ -401,22 +441,3 @@ fun Context.restartApplication() { startActivity(mainIntent) Runtime.getRuntime().exit(0) } - -fun Context.changeAppAlias(newAlias: String) { - val namespace = "com.dot.gallery" - val aliases = listOf("Launcher_ReFra", "Launcher_Gallery") - val targetAlias = "Launcher_$newAlias" - for (alias in aliases) { - val component = android.content.ComponentName(packageName, "$namespace.$alias") - val newState = if (alias == targetAlias) { - PackageManager.COMPONENT_ENABLED_STATE_ENABLED - } else { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - } - packageManager.setComponentEnabledSetting( - component, - newState, - PackageManager.DONT_KILL_APP - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/DateExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/DateExt.kt index 054b25c0dd..918b2bf0f2 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/DateExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/DateExt.kt @@ -8,15 +8,16 @@ package com.dot.gallery.feature_node.presentation.util import android.content.res.Resources import android.os.Parcelable import android.text.format.DateFormat +import androidx.compose.ui.text.intl.Locale as ComposeLocale import androidx.core.os.ConfigurationCompat -import kotlinx.parcelize.Parcelize import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.math.roundToInt -import androidx.compose.ui.text.intl.Locale as ComposeLocale +import kotlinx.parcelize.Parcelize + +private val FILENAME_DATE_REGEX = Regex("""(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})""") fun Long.getDateExt(): DateExt { val mediaDate = Calendar.getInstance(ComposeLocale.getCurrentAndroid()) @@ -97,35 +98,35 @@ fun Long.getDate( currentDate.timeInMillis = System.currentTimeMillis() val mediaDate = Calendar.getInstance(locale) mediaDate.timeInMillis = this * 1000L - val different: Long = System.currentTimeMillis() - mediaDate.timeInMillis - val secondsInMilli: Long = 1000 - val minutesInMilli = secondsInMilli * 60 - val hoursInMilli = minutesInMilli * 60 - val daysInMilli = hoursInMilli * 24 - - val daysDifference = (different / daysInMilli.toFloat()).roundToInt() - - return when (daysDifference) { - 0 -> { - if (currentDate.get(Calendar.DATE) != mediaDate.get(Calendar.DATE)) { - stringYesterday - } else { - stringToday - } - } - 1 -> { - stringYesterday - } + // Use calendar-day difference (truncate to start of day) to avoid + // grouping artifacts at day boundaries. Raw time division caused + // photos from the same calendar day to land in different groups + // when their hour-of-day differences straddled a rounding boundary. + val currentDayStart = Calendar.getInstance(locale).apply { + timeInMillis = currentDate.timeInMillis + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val mediaDayStart = Calendar.getInstance(locale).apply { + timeInMillis = mediaDate.timeInMillis + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val daysDifference = ((currentDayStart.timeInMillis - mediaDayStart.timeInMillis) / (24 * 60 * 60 * 1000L)).toInt() + return when { + daysDifference <= 0 -> stringToday + daysDifference == 1 -> stringYesterday + daysDifference in 2..6 -> DateFormat.format(weeklyFormat, mediaDate).toString() else -> { - if (daysDifference in 2..6) { - DateFormat.format(weeklyFormat, mediaDate).toString() - } else { - if (currentDate.get(Calendar.YEAR) > mediaDate.get(Calendar.YEAR)) { - DateFormat.format(extendedFormat, mediaDate).toString() - } else DateFormat.format(format, mediaDate).toString() - } + if (currentDate.get(Calendar.YEAR) > mediaDate.get(Calendar.YEAR)) { + DateFormat.format(extendedFormat, mediaDate).toString() + } else DateFormat.format(format, mediaDate).toString() } } } @@ -163,5 +164,26 @@ fun String?.formatMinSec(): String { } } +fun String.parseTimestampFromFilename(): Long? { + val match = FILENAME_DATE_REGEX.find(this) ?: return null + val (year, month, day, hour, minute, second) = match.destructured + val yearValue = year.toIntOrNull() ?: return null + val monthValue = month.toIntOrNull() ?: return null + val dayValue = day.toIntOrNull() ?: return null + val hourValue = hour.toIntOrNull() ?: return null + val minuteValue = minute.toIntOrNull() ?: return null + val secondValue = second.toIntOrNull() ?: return null + + if (yearValue !in 1970..2100) return null + + return runCatching { + Calendar.getInstance().apply { + isLenient = false + set(yearValue, monthValue - 1, dayValue, hourValue, minuteValue, secondValue) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + }.getOrNull() +} + @Parcelize data class DateExt(val month: String, val day: Int, val year: Int): Parcelable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt index 8d64121149..93659b9ee5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt @@ -1,30 +1,12 @@ -/** - * Source - * https://github.com/saparkhid/AndroidFileNamePicker/blob/main/javautil/FileUtils.java - */ package com.dot.gallery.feature_node.presentation.util -import android.annotation.SuppressLint -import android.content.ContentUris import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.provider.OpenableColumns -import android.text.TextUtils -import android.util.Log import com.dot.gallery.R -import com.dot.gallery.core.Constants import java.io.File -import java.io.FileOutputStream import java.math.RoundingMode import java.text.DecimalFormat import java.util.Locale -import java.util.UUID import kotlin.math.log10 -import kotlin.math.min import kotlin.math.pow @Suppress("NOTHING_TO_INLINE") @@ -56,295 +38,3 @@ fun File.formattedFileSize(context: Context): String { } return "${roundingSize.format(fileSize)} $fileSizeName" } - -class FileUtils(var context: Context) { - @SuppressLint("NewApi") - fun getPath(uri: Uri): String? { - val selection: String? - val selectionArgs: Array? - // ExternalStorageProvider - if (isExternalStorageDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - var fullPath = getPathFromExtSD(split) - if (fullPath == null || !fileExists(fullPath)) { - fullPath = copyFileToInternalStorage(uri, FALLBACK_COPY_FOLDER) - } - return if (fullPath != "") { - fullPath - } else { - null - } - } - // DownloadsProvider - if (isDownloadsDocument(uri)) { - context.contentResolver.query( - uri, arrayOf( - MediaStore.MediaColumns.DISPLAY_NAME - ), null, null, null - ).use { cursor -> - if (cursor != null && cursor.moveToFirst()) { - val fileName = cursor.getString(0) - val path = Environment.getExternalStorageDirectory() - .toString() + "/Download/" + fileName - if (!TextUtils.isEmpty(path)) { - return path - } - } - } - val id: String = DocumentsContract.getDocumentId(uri) - if (!TextUtils.isEmpty(id)) { - if (id.startsWith("raw:")) { - return id.replaceFirst("raw:".toRegex(), "") - } - val contentUriPrefixesToTry = arrayOf( - "content://downloads/public_downloads", - "content://downloads/my_downloads" - ) - for (contentUriPrefix in contentUriPrefixesToTry) { - return try { - val contentUri = ContentUris.withAppendedId( - Uri.parse(contentUriPrefix), - id.toLong() - ) - getDataColumn(context, contentUri, null, null) - } catch (e: NumberFormatException) { - //In Android 8 and Android P the id is not a number - uri.path!!.replaceFirst("^/document/raw:".toRegex(), "") - .replaceFirst("^raw:".toRegex(), "") - } - } - } - } - // MediaProvider - if (isMediaDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - var contentUri: Uri? = null - when (type) { - "image" -> { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - - "video" -> { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - } - selection = "_id=?" - selectionArgs = arrayOf( - split[1] - ) - return getDataColumn(context, contentUri, selection, selectionArgs) - } - if (isGoogleDriveUri(uri)) { - return getDriveFilePath(uri) - } - if (isWhatsAppFile(uri)) { - return getFilePathForWhatsApp(uri) - } - if ("content".equals(uri.scheme, ignoreCase = true)) { - if (isGooglePhotosUri(uri)) { - return uri.lastPathSegment - } - if (isGoogleDriveUri(uri)) { - return getDriveFilePath(uri) - } - if (uri.path != null && uri.path!!.contains("Android")) { - Log.d(Constants.TAG, "Uri: $uri") - return uri.toString() - } - return copyFileToInternalStorage(uri, FALLBACK_COPY_FOLDER) - } - if ("file".equals(uri.scheme, ignoreCase = true)) { - return uri.path - } - return copyFileToInternalStorage(uri, FALLBACK_COPY_FOLDER) - } - - private fun getDriveFilePath(uri: Uri): String { - val returnCursor = context.contentResolver.query(uri, null, null, null, null) - /* - * Get the column indexes of the data in the Cursor, - * * move to the first row in the Cursor, get the data, - * * and display it. - * */ - val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) - returnCursor.moveToFirst() - val name = returnCursor.getString(nameIndex) - returnCursor.close() - val file = File(context.cacheDir, name) - try { - val inputStream = context.contentResolver.openInputStream(uri) - val outputStream = FileOutputStream(file) - var read: Int - val maxBufferSize = 1 * 1024 * 1024 - val bytesAvailable = inputStream!!.available() - val bufferSize = min(bytesAvailable, maxBufferSize) - val buffers = ByteArray(bufferSize) - while (inputStream.read(buffers).also { read = it } != -1) { - outputStream.write(buffers, 0, read) - } - inputStream.close() - outputStream.close() - } catch (e: Exception) { - Log.e(TAG, e.message!!) - } - return file.path - } - - /*** - * Used for Android Q+ - * @param uri - * @param newDirName if you want to create a directory, you can set this variable - * @return - */ - private fun copyFileToInternalStorage(uri: Uri, newDirName: String): String { - val returnCursor = context.contentResolver.query( - uri, arrayOf( - OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE - ), null, null, null - ) - - - /* - * Get the column indexes of the data in the Cursor, - * * move to the first row in the Cursor, get the data, - * * and display it. - * */ - val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) - returnCursor.moveToFirst() - val name = returnCursor.getString(nameIndex) - returnCursor.close() - val output: File = if (newDirName != "") { - val randomCollisionAvoidance = UUID.randomUUID().toString() - val dir = - File(context.filesDir.toString() + File.separator + newDirName + File.separator + randomCollisionAvoidance) - if (!dir.exists()) { - dir.mkdirs() - } - File(context.filesDir.toString() + File.separator + newDirName + File.separator + randomCollisionAvoidance + File.separator + name) - } else { - File(context.filesDir.toString() + File.separator + name) - } - try { - val inputStream = context.contentResolver.openInputStream(uri) - val outputStream = FileOutputStream(output) - var read: Int - val bufferSize = 1024 - val buffers = ByteArray(bufferSize) - while (inputStream!!.read(buffers).also { read = it } != -1) { - outputStream.write(buffers, 0, read) - } - inputStream.close() - outputStream.close() - } catch (e: Exception) { - Log.e(TAG, e.message!!) - } - return output.path - } - - private fun getFilePathForWhatsApp(uri: Uri): String { - return copyFileToInternalStorage(uri, "whatsapp") - } - - private fun getDataColumn( - context: Context, - uri: Uri?, - selection: String?, - selectionArgs: Array? - ): String? { - var cursor: Cursor? = null - val column = "_data" - val projection = arrayOf( - column - ) - try { - cursor = context.contentResolver.query( - uri!!, projection, - selection, selectionArgs, null - ) - if (cursor != null && cursor.moveToFirst()) { - val index = cursor.getColumnIndexOrThrow(column) - return cursor.getString(index) - } - } finally { - cursor?.close() - } - return null - } - - private fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents" == uri.authority - } - - private fun isGooglePhotosUri(uri: Uri): Boolean { - return "com.google.android.apps.photos.content" == uri.authority - } - - private fun isWhatsAppFile(uri: Uri): Boolean { - return "com.whatsapp.provider.media" == uri.authority - } - - private fun isGoogleDriveUri(uri: Uri): Boolean { - return "com.google.android.apps.docs.storage" == uri.authority || "com.google.android.apps.docs.storage.legacy" == uri.authority - } - - companion object { - var FALLBACK_COPY_FOLDER = "upload_part" - private const val TAG = "FileUtils" - private var contentUri: Uri? = null - private fun fileExists(filePath: String): Boolean { - val file = File(filePath) - return file.exists() - } - - private fun getPathFromExtSD(pathData: Array): String? { - val type = pathData[0] - val relativePath = File.separator + pathData[1] - var fullPath: String - // on my Sony devices (4.4.4 & 5.1.1), `type` is a dynamic string - // something like "71F8-2C0A", some kind of unique id per storage - // don't know any API that can get the root path of that storage based on its id. - // - // so no "primary" type, but let the check here for other devices - if ("primary".equals(type, ignoreCase = true)) { - fullPath = Environment.getExternalStorageDirectory().toString() + relativePath - if (fileExists(fullPath)) { - return fullPath - } - } - if ("home".equals(type, ignoreCase = true)) { - fullPath = "/storage/emulated/0/Documents$relativePath" - if (fileExists(fullPath)) { - return fullPath - } - } - - // Environment.isExternalStorageRemovable() is `true` for external and internal storage - // so we cannot relay on it. - // - // instead, for each possible path, check if file exists - // we'll start with secondary storage as this could be our (physically) removable sd card - fullPath = System.getenv("SECONDARY_STORAGE")!! + relativePath - if (fileExists(fullPath)) { - return fullPath - } - fullPath = System.getenv("EXTERNAL_STORAGE")!! + relativePath - return if (fileExists(fullPath)) { - fullPath - } else null - } - - private fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents" == uri.authority - } - - private fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents" == uri.authority - } - } -} - -fun Uri.isFromApps(): Boolean = - scheme.toString() == "content" && toString().contains("Android/") \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideInvalidation.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideInvalidation.kt index 436b803aac..8acd9343e8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideInvalidation.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideInvalidation.kt @@ -8,4 +8,8 @@ object GlideInvalidation { fun signature(obj: T): Key { return ObjectKey(obj.toString()) } -} \ No newline at end of file + + fun signature(obj: T, variant: Any): Key { + return ObjectKey("${obj}:${variant}") + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideModel.kt new file mode 100644 index 0000000000..1e4a665d0d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/GlideModel.kt @@ -0,0 +1,19 @@ +package com.dot.gallery.feature_node.presentation.util + +import android.net.Uri +import com.dot.gallery.core.decoder.glide.galleryMediaModel +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.util.getUri + +internal fun Media.toGlideModel(): Any { + return galleryMediaModel(uri = getUri(), mimeType = mimeType) +} + +internal fun Album.toGlideModel(): Any { + return galleryMediaModel(uri = uri, mimeType = null) +} + +internal fun Uri.toGlideModel(mimeType: String? = null): Any { + return galleryMediaModel(uri = this, mimeType = mimeType) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt index 7f73e0d7c4..6d33139cb0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt @@ -15,7 +15,6 @@ import android.graphics.Matrix import android.net.Uri import android.provider.MediaStore import android.widget.Toast -import com.dot.gallery.core.util.SdkCompat import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest @@ -37,19 +36,12 @@ import androidx.core.net.toFile import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.rememberExifDateFormat -import com.dot.gallery.feature_node.data.data_source.KeychainHolder +import com.dot.gallery.core.util.SdkCompat +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MediaMetadata +import com.dot.gallery.feature_node.data.util.getUri import com.dot.gallery.feature_node.domain.model.InfoRow -import com.dot.gallery.feature_node.domain.model.Media - -import com.dot.gallery.feature_node.domain.model.MediaMetadata -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.domain.util.isEncrypted import com.dot.gallery.feature_node.presentation.mediaview.components.retrieveMetadata -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream val sdcardRegex = "^/storage/[A-Z0-9]+-[A-Z0-9]+/.*$".toRegex() @@ -243,43 +235,7 @@ fun Context.copyMediaToClipboard(media: T) { Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() } -suspend fun Context.copyEncryptedMediaToClipboard( - media: T, - keychainHolder: KeychainHolder -) = withContext(Dispatchers.IO) { - try { - val tempFile = createDecryptedTempFile(media, keychainHolder) - val tempUri = FileProvider.getUriForFile( - this@copyEncryptedMediaToClipboard, - BuildConfig.CONTENT_AUTHORITY, - tempFile - ) - withContext(Dispatchers.Main) { - val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newUri(contentResolver, media.label, tempUri) - clip.description.extras = android.os.PersistableBundle().apply { - putString("android.content.extra.IS_SENSITIVE", "false") - } - clipboardManager.setPrimaryClip(clip) - Toast.makeText( - this@copyEncryptedMediaToClipboard, - getString(R.string.copied_to_clipboard), - Toast.LENGTH_SHORT - ).show() - } - } catch (e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - Toast.makeText( - this@copyEncryptedMediaToClipboard, - getString(R.string.error_copying_to_clipboard), - Toast.LENGTH_SHORT - ).show() - } - } -} - -fun Context.shareMedia(media: T) { +fun Context.shareMedia(media: T): Boolean { val originalUri = media.getUri() val uri = if (originalUri.toString() .startsWith("content://") @@ -289,14 +245,18 @@ fun Context.shareMedia(media: T) { originalUri.toFile() ) - ShareCompat + val shareIntent = ShareCompat .IntentBuilder(this) .setType(media.mimeType) .addStream(uri) - .startChooser() + .createChooserIntent() + return tryStartActivity( + intent = shareIntent, + errorMessage = getString(R.string.error_toast), + ) } -fun Context.shareMedia(mediaList: List) { +fun Context.shareMedia(mediaList: List): Boolean { val mimeTypes = if (mediaList.find { it.duration != null } != null) { if (mediaList.find { it.duration == null } != null) "video/*,image/*" else "video/*" @@ -308,168 +268,8 @@ fun Context.shareMedia(mediaList: List) { mediaList.forEach { shareCompat.addStream(it.getUri()) } - shareCompat.startChooser() -} - -/** - * Share encrypted media from vault by temporarily decrypting it - */ -suspend fun Context.shareEncryptedMedia( - media: T, - vault: Vault, - keychainHolder: KeychainHolder -) = withContext(Dispatchers.IO) { - try { - // Create temporary file for decrypted content - val tempFile = createDecryptedTempFile(media, keychainHolder) - - // Create content URI for the temp file - val tempUri = FileProvider.getUriForFile( - this@shareEncryptedMedia, - BuildConfig.CONTENT_AUTHORITY, - tempFile - ) - - // Share the decrypted file - withContext(Dispatchers.Main) { - val shareIntent = ShareCompat - .IntentBuilder(this@shareEncryptedMedia) - .setType(media.mimeType) - .addStream(tempUri) - .createChooserIntent() - .apply { - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - startActivity(shareIntent) - } - - } catch (e: Exception) { - e.printStackTrace() - // Fallback to regular sharing if decryption fails - withContext(Dispatchers.Main) { - shareMedia(media) - } - } -} - -/** - * Share multiple media items, handling both encrypted and non-encrypted media - */ -suspend fun Context.shareMediaWithVaultSupport( - mediaList: List, - currentVault: Vault? = null -) = withContext(Dispatchers.IO) { - try { - val keychainHolder = if (currentVault != null) KeychainHolder(this@shareMediaWithVaultSupport) else null - val shareStreams = mutableListOf() - - for (media in mediaList) { - if (media.isEncrypted && currentVault != null && keychainHolder != null) { - // Handle encrypted media by creating temp decrypted file - try { - val tempFile = createDecryptedTempFile(media, keychainHolder) - val tempUri = FileProvider.getUriForFile( - this@shareMediaWithVaultSupport, - BuildConfig.CONTENT_AUTHORITY, - tempFile - ) - shareStreams.add(tempUri) - } catch (e: Exception) { - // If decryption fails, skip this media or use original URI - e.printStackTrace() - shareStreams.add(media.getUri()) - } - } else { - // Handle regular media - val originalUri = media.getUri() - val uri = if (originalUri.toString().startsWith("content://")) { - originalUri - } else { - FileProvider.getUriForFile( - this@shareMediaWithVaultSupport, - BuildConfig.CONTENT_AUTHORITY, - originalUri.toFile() - ) - } - shareStreams.add(uri) - } - } - - if (shareStreams.isNotEmpty()) { - // Determine appropriate mime type - val mimeTypes = if (mediaList.find { it.duration != null } != null) { - if (mediaList.find { it.duration == null } != null) "video/*,image/*" else "video/*" - } else "image/*" - - withContext(Dispatchers.Main) { - val shareBuilder = ShareCompat - .IntentBuilder(this@shareMediaWithVaultSupport) - .setType(mimeTypes) - - shareStreams.forEach { uri -> - shareBuilder.addStream(uri) - } - - val shareIntent = shareBuilder.createChooserIntent().apply { - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - startActivity(shareIntent) - } - } - } catch (e: Exception) { - e.printStackTrace() - // Fallback to regular sharing - withContext(Dispatchers.Main) { - shareMedia(mediaList) - } - } -} - -/** - * Create a temporary decrypted file from encrypted vault media - */ -private fun createDecryptedTempFile( - media: T, - keychainHolder: KeychainHolder -): File { - // Get the encrypted file - val encryptedFile = media.getUri().toFile() - - // Decrypt the media - val decryptedMedia = keychainHolder.decryptVaultMedia(encryptedFile) - - // Create temp file with appropriate extension - val extension = when { - media.mimeType.startsWith("image/") -> when (media.mimeType) { - "image/jpeg" -> ".jpg" - "image/png" -> ".png" - "image/gif" -> ".gif" - "image/webp" -> ".webp" - else -> ".jpg" - } - media.mimeType.startsWith("video/") -> when (media.mimeType) { - "video/mp4" -> ".mp4" - "video/avi" -> ".avi" - "video/mov" -> ".mov" - "video/webm" -> ".webm" - else -> ".vid" - } - else -> ".mp4" - } - - val tempFile = File.createTempFile( - "shared_${media.label.replace("[^a-zA-Z0-9]".toRegex(), "_")}_", - extension + return tryStartActivity( + intent = shareCompat.createChooserIntent(), + errorMessage = getString(R.string.error_toast), ) - - // Write decrypted content to temp file (streaming for large files) - decryptedMedia.openStream().use { input -> - FileOutputStream(tempFile).use { outputStream -> - input.copyTo(outputStream) - } - } - - return tempFile -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MockedProviders.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MockedProviders.kt index d984aa6fbd..4cce9360b1 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MockedProviders.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MockedProviders.kt @@ -11,34 +11,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import com.dot.gallery.core.MediaDistributor -import com.dot.gallery.core.presentation.components.MediaImageRenderer import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaSelector +import com.dot.gallery.core.presentation.components.MediaImageRenderer import com.dot.gallery.core.util.SetupMediaProviders +import com.dot.gallery.feature_node.data.model.CollectionWithCount +import com.dot.gallery.feature_node.data.model.IgnoredAlbum +import com.dot.gallery.feature_node.data.model.LockedAlbum +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.MergedSubfolderAlbum +import com.dot.gallery.feature_node.data.model.PinnedAlbum +import com.dot.gallery.feature_node.data.model.TimelineSettings +import com.dot.gallery.feature_node.data.util.MediaGroupType import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.CollectionWithCount -import com.dot.gallery.feature_node.domain.model.IgnoredAlbum -import com.dot.gallery.feature_node.domain.model.ImageEmbedding -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.LocationMedia import com.dot.gallery.feature_node.domain.model.MediaMetadataState import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.LockedAlbum -import com.dot.gallery.feature_node.domain.model.MergedSubfolderAlbum -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.model.TimelineSettings import com.dot.gallery.feature_node.domain.model.UIEvent -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState import com.dot.gallery.feature_node.domain.util.EventHandler -import com.dot.gallery.feature_node.domain.util.MediaGroupType +import java.util.UUID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow -import java.util.UUID class MockedEventHandler: EventHandler { override val updaterFlow: Flow = emptyFlow() @@ -70,15 +64,6 @@ open class MockedMediaDistributor: MediaDistributor { override val favoritesMediaFlow: StateFlow> = MutableStateFlow(MediaState()) override val trashMediaFlow: StateFlow> = MutableStateFlow(MediaState()) override val metadataFlow: StateFlow = MutableStateFlow(MediaMetadataState()) - override val locationsMediaFlow: SharedFlow> = MutableStateFlow(emptyList()) - override val geoMediaFlow: StateFlow> = MutableStateFlow(emptyList()) - override val vaultsMediaFlow: StateFlow = MutableStateFlow(VaultState()) - override fun vaultMediaFlow(vault: Vault?): StateFlow> = MutableStateFlow(MediaState()) - override val imageEmbeddingsFlow: StateFlow> = MutableStateFlow(emptyList()) - override fun locationBasedMedia( - gpsLocationNameCity: String, - gpsLocationNameCountry: String - ): Flow> = emptyFlow() override val collectionsFlow: StateFlow> = MutableStateFlow(emptyList()) override val collectionAlbumIdsFlow: StateFlow> = MutableStateFlow(emptySet()) override fun collectionAlbumIdsInCollection(collectionId: Long): Flow> = emptyFlow() @@ -146,15 +131,6 @@ class MockedMediaHandler: MediaHandler { displayName: String ): Uri? = null - override suspend fun overrideImage( - uri: Uri, - bitmap: Bitmap, - format: Bitmap.CompressFormat, - mimeType: String, - relativePath: String, - displayName: String - ): Boolean = false - override suspend fun getCategoryForMediaId(mediaId: Long): String? = null override fun getClassifiedMediaCountAtCategory(category: String): Flow = emptyFlow() override fun getClassifiedMediaThumbnailByCategory(category: String): Flow = emptyFlow() @@ -162,7 +138,6 @@ class MockedMediaHandler: MediaHandler { override suspend fun updateAlbumThumbnail(albumId: Long, newThumbnail: Uri) = Unit override fun hasAlbumThumbnail(albumId: Long): Flow = emptyFlow() override suspend fun collectMetadataFor(media: Media) = Unit - override suspend fun addMedia(vault: Vault, media: T) = Unit override fun rotateImage( media: T, degrees: Int @@ -214,4 +189,4 @@ fun SetupMockedMediaProviders(content: @Composable () -> Unit) { mediaImageRenderer = MockedMediaImageRenderer, content = content ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MultiSelectExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MultiSelectExt.kt index 4f375cbdd1..b4da853a7c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MultiSelectExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/MultiSelectExt.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toIntRect -import com.dot.gallery.feature_node.domain.util.isHeaderKey -import com.dot.gallery.feature_node.domain.util.isIgnoredKey +import com.dot.gallery.feature_node.data.util.isHeaderKey +import com.dot.gallery.feature_node.data.util.isIgnoredKey private val String?.mediaIdFromKey: Long? get() = this?.let { @@ -26,7 +26,7 @@ private val String?.mediaIdFromKey: Long? private fun LazyGridState.hitKeyAt( raw: Offset, padL: Float, - padT: Float + padT: Float, ): String? { val contentOffset = raw - Offset(padL, padT) return layoutInfo.visibleItemsInfo @@ -37,38 +37,46 @@ private fun LazyGridState.hitKeyAt( ?.key as? String } -private data class HitInfo(val key: String, val normalizedX: Float, val normalizedY: Float) +private class HitInfo( + val key: String, + val normalizedX: Float, + val normalizedY: Float, +) private fun LazyGridState.hitInfoAt( raw: Offset, padL: Float, - padT: Float + padT: Float, ): HitInfo? { val contentOffset = raw - Offset(padL, padT) val info = layoutInfo.visibleItemsInfo .find { it.size.toIntRect().contains((contentOffset.round() - it.offset)) } ?: return null val key = info.key as? String ?: return null - val rel = contentOffset - Offset(info.offset.x.toFloat(), info.offset.y.toFloat()) + val relativeOffset = contentOffset - Offset(info.offset.x.toFloat(), info.offset.y.toFloat()) return HitInfo( key = key, - normalizedX = (rel.x / info.size.width).coerceIn(0f, 1f), - normalizedY = (rel.y / info.size.height).coerceIn(0f, 1f) + normalizedX = (relativeOffset.x / info.size.width).coerceIn(0f, 1f), + normalizedY = (relativeOffset.y / info.size.height).coerceIn(0f, 1f), ) } private fun resolveHitIds( hit: HitInfo, - allIds: List -): List = when { - hit.key.startsWith("mosaic_pair_") && allIds.size == 2 -> - listOf(if (hit.normalizedY < 0.5f) allIds[0] else allIds[1]) - hit.key.startsWith("mosaic_quad_") && allIds.size == 4 -> { - val col = if (hit.normalizedX < 0.5f) 0 else 1 - val row = if (hit.normalizedY < 0.5f) 0 else 1 - listOf(allIds[row * 2 + col]) + allIds: List, +): List { + return when { + hit.key.startsWith("mosaic_pair_") && allIds.size == 2 -> + listOf(if (hit.normalizedY < 0.5f) allIds[0] else allIds[1]) + + hit.key.startsWith("mosaic_quad_") && allIds.size == 4 -> { + val column = if (hit.normalizedX < 0.5f) 0 else 1 + val row = if (hit.normalizedY < 0.5f) 0 else 1 + listOf(allIds[row * 2 + column]) + } + + else -> allIds } - else -> allIds } fun Modifier.mosaicGridDragHandler( @@ -82,74 +90,86 @@ fun Modifier.mosaicGridDragHandler( layoutDirection: LayoutDirection, contentPadding: PaddingValues, orderedGridKeys: List, - gridKeyToMediaIds: Map> -) = pointerInput(Unit) { - val padL = contentPadding.calculateLeftPadding(layoutDirection).toPx() - val padT = contentPadding.calculateTopPadding().toPx() - - var initialIndex: Int? = null - var currentIndex: Int? = null - - detectDragGesturesAfterLongPress( - onDragStart = { raw -> - scrollGestureActive.value = true - lazyGridState.hitInfoAt(raw, padL, padT)?.let { hit -> - val idx = orderedGridKeys.indexOf(hit.key) - val allIds = gridKeyToMediaIds[hit.key] ?: emptyList() - if (idx >= 0 && allIds.isNotEmpty()) { - val hitIds = resolveHitIds(hit, allIds).toSet() - if (!selectedIds.value.containsAll(hitIds)) { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - initialIndex = idx - currentIndex = idx - updateSelectedIds(selectedIds.value + hitIds) + gridKeyToMediaIds: Map>, +): Modifier { + val gridKeyToIndex = orderedGridKeys.withIndex().associate { (index, key) -> key to index } + return pointerInput(orderedGridKeys, gridKeyToMediaIds, contentPadding, layoutDirection) { + val leftPadding = contentPadding.calculateLeftPadding(layoutDirection).toPx() + val topPadding = contentPadding.calculateTopPadding().toPx() + + var initialIndex: Int? = null + var currentIndex: Int? = null + + detectDragGesturesAfterLongPress( + onDragStart = { rawOffset -> + scrollGestureActive.value = true + lazyGridState.hitInfoAt(rawOffset, leftPadding, topPadding)?.let { hit -> + val hitIndex = gridKeyToIndex[hit.key] ?: -1 + val hitMediaIds = gridKeyToMediaIds[hit.key].orEmpty() + if (hitIndex >= 0 && hitMediaIds.isNotEmpty()) { + val selectedHitIds = resolveHitIds(hit, hitMediaIds).toSet() + if (!selectedIds.value.containsAll(selectedHitIds)) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + updateSelectedIds(selectedIds.value + selectedHitIds) + } + initialIndex = hitIndex + currentIndex = hitIndex } } - } - }, - onDragCancel = { - scrollGestureActive.value = false - initialIndex = null - autoScrollSpeed.value = 0f - }, - onDragEnd = { - scrollGestureActive.value = false - initialIndex = null - autoScrollSpeed.value = 0f - }, - onDrag = { change, _ -> - val raw = change.position - if (initialIndex != null) { - val distB = lazyGridState.layoutInfo.viewportSize.height - raw.y - val distT = raw.y - autoScrollSpeed.value = when { - distB < autoScrollThreshold -> autoScrollThreshold - distB - distT < autoScrollThreshold -> -(autoScrollThreshold - distT) - else -> 0f - } + }, + onDragCancel = { + scrollGestureActive.value = false + initialIndex = null + autoScrollSpeed.value = 0f + }, + onDragEnd = { + scrollGestureActive.value = false + initialIndex = null + autoScrollSpeed.value = 0f + }, + onDrag = { change, _ -> + val rawOffset = change.position + val initialDragIndex = initialIndex + if (initialDragIndex != null) { + val distanceFromBottom = + lazyGridState.layoutInfo.viewportSize.height - rawOffset.y + val distanceFromTop = rawOffset.y + autoScrollSpeed.value = when { + distanceFromBottom < autoScrollThreshold -> + autoScrollThreshold - distanceFromBottom + distanceFromTop < autoScrollThreshold -> + -(autoScrollThreshold - distanceFromTop) + else -> 0f + } - lazyGridState.hitKeyAt(raw, padL, padT)?.let { key -> - val newIdx = orderedGridKeys.indexOf(key) - if (newIdx >= 0 && newIdx != currentIndex) { - val start = initialIndex!! - val oldEnd = currentIndex!! - val oldRange = if (oldEnd >= start) start..oldEnd else oldEnd..start - val newRange = if (newIdx >= start) start..newIdx else newIdx..start + lazyGridState.hitKeyAt(rawOffset, leftPadding, topPadding)?.let { key -> + val newIndex = gridKeyToIndex[key] ?: -1 + val previousIndex = currentIndex + if (newIndex >= 0 && previousIndex != null && newIndex != previousIndex) { + val oldRange = when { + previousIndex >= initialDragIndex -> initialDragIndex..previousIndex + else -> previousIndex..initialDragIndex + } + val newRange = when { + newIndex >= initialDragIndex -> initialDragIndex..newIndex + else -> newIndex..initialDragIndex + } - val oldIds = oldRange.flatMapTo(mutableSetOf()) { - gridKeyToMediaIds[orderedGridKeys.getOrNull(it)] ?: emptyList() - } - val newIds = newRange.flatMapTo(mutableSetOf()) { - gridKeyToMediaIds[orderedGridKeys.getOrNull(it)] ?: emptyList() - } + val oldIds = oldRange.flatMapTo(mutableSetOf()) { index -> + gridKeyToMediaIds[orderedGridKeys[index]].orEmpty() + } + val newIds = newRange.flatMapTo(mutableSetOf()) { index -> + gridKeyToMediaIds[orderedGridKeys[index]].orEmpty() + } - updateSelectedIds(selectedIds.value - oldIds + newIds) - currentIndex = newIdx + updateSelectedIds(selectedIds.value - oldIds + newIds) + currentIndex = newIndex + } } } - } - }, - ) + }, + ) + } } fun Modifier.photoGridDragHandler( @@ -162,70 +182,82 @@ fun Modifier.photoGridDragHandler( scrollGestureActive: MutableState, layoutDirection: LayoutDirection, contentPadding: PaddingValues, - allKeys: List -) = pointerInput(Unit) { - // pre-compute the corresponding IDs - val mediaIdsInOrder = allKeys.mapNotNull { it.mediaIdFromKey } - - val padL = contentPadding.calculateLeftPadding(layoutDirection).toPx() - val padT = contentPadding.calculateTopPadding().toPx() - - var initialMediaIndex: Int? = null - var currentMediaIndex: Int? = null - - detectDragGesturesAfterLongPress( - onDragStart = { raw -> - scrollGestureActive.value = true - lazyGridState.hitKeyAt(raw, padL, padT)?.let { key -> - val idx = allKeys.indexOf(key) - val id = key.mediaIdFromKey - if (idx >= 0 && id != null && id !in selectedIds.value) { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - initialMediaIndex = idx - currentMediaIndex = idx - updateSelectedIds(selectedIds.value + id) - } - } - }, - onDragCancel = { - scrollGestureActive.value = false - initialMediaIndex = null - autoScrollSpeed.value = 0f - }, - onDragEnd = { - scrollGestureActive.value = false - initialMediaIndex = null - autoScrollSpeed.value = 0f - }, - onDrag = { change, _ -> - val raw = change.position - if (initialMediaIndex != null) { - val distB = lazyGridState.layoutInfo.viewportSize.height - raw.y - val distT = raw.y - autoScrollSpeed.value = when { - distB < autoScrollThreshold -> autoScrollThreshold - distB - distT < autoScrollThreshold -> -(autoScrollThreshold - distT) - else -> 0f + allKeys: List, +): Modifier { + val mediaKeysInOrder = allKeys.filter { key -> key.mediaIdFromKey != null } + val mediaIdsInOrder = mediaKeysInOrder.mapNotNull { key -> key.mediaIdFromKey } + val keyToIndex = mediaKeysInOrder.withIndex().associate { (index, key) -> key to index } + + return pointerInput(allKeys, contentPadding, layoutDirection) { + val leftPadding = contentPadding.calculateLeftPadding(layoutDirection).toPx() + val topPadding = contentPadding.calculateTopPadding().toPx() + + var initialMediaIndex: Int? = null + var currentMediaIndex: Int? = null + + detectDragGesturesAfterLongPress( + onDragStart = { rawOffset -> + scrollGestureActive.value = true + lazyGridState.hitKeyAt(rawOffset, leftPadding, topPadding)?.let { key -> + val mediaIndex = keyToIndex[key] ?: -1 + val mediaId = key.mediaIdFromKey + if (mediaIndex >= 0 && mediaId != null) { + if (mediaId !in selectedIds.value) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + updateSelectedIds(selectedIds.value + mediaId) + } + initialMediaIndex = mediaIndex + currentMediaIndex = mediaIndex + } } + }, + onDragCancel = { + scrollGestureActive.value = false + initialMediaIndex = null + autoScrollSpeed.value = 0f + }, + onDragEnd = { + scrollGestureActive.value = false + initialMediaIndex = null + autoScrollSpeed.value = 0f + }, + onDrag = { change, _ -> + val rawOffset = change.position + val initialDragIndex = initialMediaIndex + if (initialDragIndex != null) { + val distanceFromBottom = + lazyGridState.layoutInfo.viewportSize.height - rawOffset.y + val distanceFromTop = rawOffset.y + autoScrollSpeed.value = when { + distanceFromBottom < autoScrollThreshold -> + autoScrollThreshold - distanceFromBottom + distanceFromTop < autoScrollThreshold -> + -(autoScrollThreshold - distanceFromTop) + else -> 0f + } - lazyGridState.hitKeyAt(raw, padL, padT)?.let { key -> - val newIdx = allKeys.indexOf(key) - if (newIdx >= 0 && newIdx != currentMediaIndex) { - val start = initialMediaIndex!! - val oldEnd = currentMediaIndex!! - val oldRange = if (oldEnd >= start) start..oldEnd else oldEnd..start - val newRange = if (newIdx >= start) start..newIdx else newIdx..start - - // map to real IDs - val oldIds = oldRange.mapNotNull { mediaIdsInOrder.getOrNull(it) }.toSet() - val newIds = newRange.mapNotNull { mediaIdsInOrder.getOrNull(it) }.toSet() - - // subtract oldRange, add newRange - updateSelectedIds(selectedIds.value - oldIds + newIds) - currentMediaIndex = newIdx + lazyGridState.hitKeyAt(rawOffset, leftPadding, topPadding)?.let { key -> + val newIndex = keyToIndex[key] ?: -1 + val previousIndex = currentMediaIndex + if (newIndex >= 0 && previousIndex != null && newIndex != previousIndex) { + val oldRange = when { + previousIndex >= initialDragIndex -> initialDragIndex..previousIndex + else -> previousIndex..initialDragIndex + } + val newRange = when { + newIndex >= initialDragIndex -> initialDragIndex..newIndex + else -> newIndex..initialDragIndex + } + + val oldIds = oldRange.map { index -> mediaIdsInOrder[index] }.toSet() + val newIds = newRange.map { index -> mediaIdsInOrder[index] }.toSet() + + updateSelectedIds(selectedIds.value - oldIds + newIds) + currentMediaIndex = newIndex + } } } - } - }, - ) -} \ No newline at end of file + }, + ) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/NetworkExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/NetworkExt.kt index 35bbd1d221..d62e61d057 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/NetworkExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/NetworkExt.kt @@ -42,7 +42,14 @@ private fun Context.observeConnectivityAsFlow() = callbackFlow { .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() - connectivityManager.registerNetworkCallback(networkRequest, callback) + try { + connectivityManager.registerNetworkCallback(networkRequest, callback) + } catch (_: SecurityException) { + // ACCESS_NETWORK_STATE permission not available + trySend(ConnectionState.Unavailable) + awaitClose() + return@callbackFlow + } // Set current state val currentState = getCurrentConnectivityState(connectivityManager) @@ -81,10 +88,15 @@ private fun networkCallback(callback: (ConnectionState) -> Unit): ConnectivityMa private fun getCurrentConnectivityState( connectivityManager: ConnectivityManager ): ConnectionState { - val connected = connectivityManager.allNetworks.any { network -> - connectivityManager.getNetworkCapabilities(network) - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - ?: false + val connected = try { + connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + ?: false + } + } catch (_: SecurityException) { + // ACCESS_NETWORK_STATE permission not available + false } return if (connected) ConnectionState.Available else ConnectionState.Unavailable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt index 1e7d7dad80..355e23c2b4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt @@ -39,17 +39,6 @@ sealed class Screen(val route: String) { fun idAndCollection(id: Long, collectionId: Long) = "$route?mediaId=$id&collectionId=$collectionId" - fun idAndLocation() = "$route?mediaId={mediaId}&gpsLocationNameCity={gpsLocationNameCity}&gpsLocationNameCountry={gpsLocationNameCountry}" - - fun idAndLocation(id: Long, gpsLocationNameCity: String, gpsLocationNameCountry: String) = "$route?mediaId=$id&gpsLocationNameCity=$gpsLocationNameCity&gpsLocationNameCountry=$gpsLocationNameCountry" - } - - data object LocationTimelineScreen : Screen("location_timeline_screen") { - - fun location() = "$route?gpsLocationNameCity={gpsLocationNameCity}&gpsLocationNameCountry={gpsLocationNameCountry}" - - fun location(gpsLocationNameCity: String, gpsLocationNameCountry: String) = "$route?gpsLocationNameCity=$gpsLocationNameCity&gpsLocationNameCountry=$gpsLocationNameCountry" - } data object TrashedScreen : Screen("trashed_screen") @@ -59,8 +48,7 @@ sealed class Screen(val route: String) { data object ColorPaletteScreen : Screen("color_palette_screen") data object SettingsGeneralScreen : Screen("settings_general_screen") data object SettingsSmartFeaturesScreen : Screen("settings_smart_features_screen") - data object AIModelsManagerScreen : Screen("ai_models_manager_screen") - data object EditBackupsViewerScreen : Screen("edit_backups_viewer_screen") + data object SettingsSecurityScreen : Screen("settings_security_screen") data object SettingsAppearanceScreen : Screen("settings_appearance_screen") data object SettingsTimelineAlbumsScreen : Screen("settings_timeline_albums_screen") data object SettingsMediaViewerScreen : Screen("settings_media_viewer_screen") @@ -71,22 +59,12 @@ sealed class Screen(val route: String) { data object SetupScreen: Screen("setup_screen") - data object VaultScreen : Screen("vault_screen") - data object LibraryScreen : Screen("library_screen") data object CategoriesScreen : Screen("categories_screen") data object CategoriesSettingsScreen : Screen("categories_settings_screen") - data object LocationsScreen : Screen("locations_screen") { - - fun withMediaId() = "$route?mediaId={mediaId}" - - fun withMediaId(mediaId: Long) = "$route?mediaId=$mediaId" - - } - data object CategoryViewScreen : Screen("category_view_screen") { fun category() = "$route?category={category}" @@ -179,7 +157,5 @@ sealed class Screen(val route: String) { fun tipId(id: String) = "$route?tipId=$id" } - data object WhatsNewScreen : Screen("whats_new_screen") - operator fun invoke() = route } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SharedElementsExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SharedElementsExt.kt index 0c9e77d87e..7ddf5e2f1e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SharedElementsExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SharedElementsExt.kt @@ -4,17 +4,19 @@ package com.dot.gallery.feature_node.presentation.util import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import com.dot.gallery.core.Settings -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.data.model.Album +import com.dot.gallery.feature_node.data.model.Media + +sealed interface MediaSharedElementKey { + data class MediaKey(val key: String) : MediaSharedElementKey + data class AlbumKey(val key: String) : MediaSharedElementKey + data class CategoryKey(val id: Long) : MediaSharedElementKey +} context(namedSharedTransitionScope: SharedTransitionScope) @Composable @@ -23,7 +25,7 @@ fun Modifier.mediaSharedElement( allowAnimation: Boolean = true, media: T, animatedVisibilityScope: AnimatedVisibilityScope -): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = media.idLessKey, animatedVisibilityScope = animatedVisibilityScope) +): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = MediaSharedElementKey.MediaKey(media.idLessKey), animatedVisibilityScope = animatedVisibilityScope) context(namedSharedTransitionScope: SharedTransitionScope) @Composable @@ -32,7 +34,7 @@ fun Modifier.mediaSharedElement( allowAnimation: Boolean = true, album: Album, animatedVisibilityScope: AnimatedVisibilityScope -): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = album.idLessKey, animatedVisibilityScope = animatedVisibilityScope) +): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = MediaSharedElementKey.AlbumKey(album.idLessKey), animatedVisibilityScope = animatedVisibilityScope) context(namedSharedTransitionScope: SharedTransitionScope) @Composable @@ -41,23 +43,20 @@ fun Modifier.categorySharedElement( allowAnimation: Boolean = true, categoryId: Long, animatedVisibilityScope: AnimatedVisibilityScope -): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = "category_$categoryId", animatedVisibilityScope = animatedVisibilityScope) +): Modifier = mediaSharedElement(allowAnimation = allowAnimation, key = MediaSharedElementKey.CategoryKey(categoryId), animatedVisibilityScope = animatedVisibilityScope) context(namedSharedTransitionScope: SharedTransitionScope) @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun Modifier.mediaSharedElement( allowAnimation: Boolean = true, - key: String, + key: MediaSharedElementKey, animatedVisibilityScope: AnimatedVisibilityScope ): Modifier = with(namedSharedTransitionScope) { val shouldAnimate by Settings.Misc.rememberSharedElements() - val boundsModifier = sharedBounds( - sharedContentState = rememberSharedContentState(key = "media_$key"), - animatedVisibilityScope = animatedVisibilityScope, - enter = fadeIn(), - exit = fadeOut(), - resizeMode = scaleToBounds(contentScale = ContentScale.Crop), + val boundsModifier = sharedElement( + sharedContentState = rememberSharedContentState(key = key), + animatedVisibilityScope = animatedVisibilityScope ) return remember(shouldAnimate, allowAnimation) { if (shouldAnimate && allowAnimation) boundsModifier else Modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt index 5ff96e55f9..e0ae366db8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt @@ -20,16 +20,16 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.dot.gallery.core.Constants import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.UriMedia -import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.Media.UriMedia +import com.dot.gallery.feature_node.data.model.MediaItem +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.data.util.MediaGroupType +import com.dot.gallery.feature_node.data.util.MediaOrder +import com.dot.gallery.feature_node.data.util.classifyGroupType +import com.dot.gallery.feature_node.data.util.groupKey +import com.dot.gallery.feature_node.data.util.selectRepresentative import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.MediaGroupType -import com.dot.gallery.feature_node.domain.util.classifyGroupType -import com.dot.gallery.feature_node.domain.util.groupKey -import com.dot.gallery.feature_node.domain.util.selectRepresentative import com.dot.gallery.feature_node.presentation.mediaview.rememberedDerivedState import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import kotlinx.coroutines.Dispatchers @@ -275,7 +275,7 @@ suspend fun mapMediaToItem( isLoading = false, error = error, media = data, - pagerMedia = if (groupSimilarMedia) pagerMediaList else data, + pagerMedia = (if (groupSimilarMedia) pagerMediaList else data).distinctBy { it.id }, mediaGroups = mediaGroupsMap, headers = headers, mappedMedia = mappedData, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StaticMapURL.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StaticMapURL.kt deleted file mode 100644 index 0496363da1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StaticMapURL.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.util - -import kotlin.math.floor -import kotlin.math.ln -import kotlin.math.tan - -/** - * Generates a static map tile URL for a given lat/lng. - * No API key required. Uses Carto CDN basemap tiles (light/dark). - */ -object StaticMapURL { - - private const val CARTO_LIGHT = "https://basemaps.cartocdn.com/rastertiles/voyager" - private const val CARTO_DARK = "https://basemaps.cartocdn.com/rastertiles/dark_all" - - operator fun invoke( - latitude: Double, - longitude: Double, - darkTheme: Boolean = false, - zoom: Int = 12, - ): String { - val x = lonToTileX(longitude, zoom) - val y = latToTileY(latitude, zoom) - val base = if (darkTheme) CARTO_DARK else CARTO_LIGHT - return "$base/$zoom/$x/$y@2x.png" - } - - private fun lonToTileX(lon: Double, zoom: Int): Int { - return floor((lon + 180.0) / 360.0 * (1 shl zoom)).toInt() - } - - private fun latToTileY(lat: Double, zoom: Int): Int { - val latRad = Math.toRadians(lat) - return floor( - (1.0 - ln(tan(latRad) + 1.0 / kotlin.math.cos(latRad)) / Math.PI) / 2.0 * (1 shl zoom) - ).toInt() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SurfaceViewHazeBridge.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SurfaceViewHazeBridge.kt index f39f46d34f..97e82dd91a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SurfaceViewHazeBridge.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/SurfaceViewHazeBridge.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.graphics.asComposeRenderEffect import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.IntSize -import kotlinx.coroutines.delay import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.delay /** * Finds the first [SurfaceView] descendant in a view hierarchy. diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/launchMap.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/launchMap.kt index b610a8c79c..1d88b84df5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/launchMap.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/launchMap.kt @@ -7,13 +7,15 @@ package com.dot.gallery.feature_node.presentation.util import android.content.Context import android.content.Intent -import android.net.Uri +import androidx.core.net.toUri import com.dot.gallery.R fun Context.launchMap(lat: Double, lang: Double) { - startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse("geo:0,0?q=$lat,$lang(${getString(R.string.media_location)})") - } + val intent = Intent(Intent.ACTION_VIEW).apply { + data = "geo:0,0?q=$lat,$lang(${getString(R.string.media_location)})".toUri() + } + tryStartActivity( + intent = intent, + errorMessage = getString(R.string.error_toast), ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt deleted file mode 100644 index a2c1fdcb5c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt +++ /dev/null @@ -1,597 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteOutline -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.LockOpen -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.Restore -import androidx.compose.material.icons.rounded.ArrowDropDown -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastCoerceAtLeast -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.R -import com.dot.gallery.core.Constants.Animation.enterAnimation -import com.dot.gallery.core.Constants.Animation.exitAnimation -import com.dot.gallery.core.Constants.cellsList -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.LocalMediaSelector -import com.dot.gallery.core.Settings -import com.dot.gallery.core.Settings.Misc.rememberGridSize -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.EmptyMedia -import com.dot.gallery.core.presentation.components.ModalSheet -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.core.presentation.components.SelectionSheet -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.presentation.common.components.GridPinchZoomLayout -import com.dot.gallery.feature_node.presentation.common.components.MediaGridView -import com.dot.gallery.feature_node.presentation.common.components.OptionItem -import com.dot.gallery.feature_node.presentation.common.components.OptionSheet -import com.dot.gallery.feature_node.presentation.common.components.rememberGridPinchZoomState -import com.dot.gallery.feature_node.presentation.picker.PickerActivityContract -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.rememberActivityResult -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.selectedMedia -import com.dot.gallery.feature_node.presentation.vault.components.AddToVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.DeleteVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.NewVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.RemovePasswordSheet -import com.dot.gallery.feature_node.presentation.vault.components.RestoreVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet -import com.dot.gallery.feature_node.presentation.vault.components.VaultPasswordSetupSheet -import com.dot.gallery.feature_node.presentation.vault.utils.VaultPasswordManager -import dev.chrisbanes.haze.hazeSource -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@OptIn( - ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class, - ExperimentalFoundationApi::class -) -@Composable -fun VaultDisplay( - globalNavigateUp: () -> Unit, - vaultState: State, - currentVault: MutableStateFlow, - createMediaState: (Vault?) -> StateFlow>, - onCreateVaultClick: () -> Unit, - deleteLeftovers: (result: ActivityResultLauncher, uris: List) -> Unit, - setVault: (Vault) -> Unit, - deleteVault: (Vault) -> Unit, - restoreVault: (Vault) -> Unit, - workerProgress: StateFlow, - workerIsRunning: StateFlow, - sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope, - metadataState: State, - onAuthenticateVault: (Vault) -> Unit = {}, - onVaultDeleted: () -> Unit = {}, - encryptAndRequestDeletion: (Vault, List) -> Unit = { _, _ -> }, - addMediaKeepOriginals: (Vault, List) -> Unit = { _, _ -> }, - pendingDeletions: Flow> = emptyFlow(), - userMessage: Flow? = null, - onMediaClick: (Long) -> Unit = {}, -) { - val eventHandler = LocalEventHandler.current - val isRunning by workerIsRunning.collectAsStateWithLifecycle() - val progress by workerProgress.collectAsStateWithLifecycle() - val currentVaultValue by currentVault.collectAsStateWithLifecycle() - val mediaState = remember(currentVaultValue) { - createMediaState(currentVaultValue) - }.collectAsStateWithLifecycle() - - // Keep current vault in sync; if deleted or null, navigate back - LaunchedEffect(vaultState.value, currentVaultValue) { - val current = currentVaultValue - val vaults = vaultState.value.vaults - if (current != null && vaults.any { it.uuid == current.uuid }) { - setVault(current) - } else if (current == null && vaults.isNotEmpty()) { - // Vault was deleted but others remain — go to vault selector for auth - onVaultDeleted() - } else if (current == null && vaults.isEmpty()) { - // Last vault deleted — exit to library - globalNavigateUp() - } - } - - var lastCellIndex by rememberGridSize() - - val dpCacheWindow = remember { - LazyLayoutCacheWindow(ahead = 200.dp, behind = 100.dp) - } - val pinchState = rememberGridPinchZoomState( - cellsList = cellsList, - initialCellsIndex = lastCellIndex, - gridState = rememberLazyGridState( - cacheWindow = dpCacheWindow - ) - ) - - var canScroll by rememberSaveable { mutableStateOf(true) } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( - state = rememberTopAppBarState(), - canScroll = { canScroll }, - ) - - LaunchedEffect(pinchState.isZooming) { - canScroll = !pinchState.isZooming - lastCellIndex = cellsList.indexOf(pinchState.currentCells) - } - - val scope = rememberCoroutineScope() - val bottomSheetState = rememberAppBottomSheetState() - - var toAddMedia by remember { mutableStateOf>(emptyList()) } - - var pickedUris by remember { mutableStateOf>(emptyList()) } - val addToVaultSheetState = rememberAppBottomSheetState() - var vaultEncryptBehavior by Settings.Vault.rememberVaultEncryptBehavior() - - val pickerLauncher = rememberLauncherForActivityResult(PickerActivityContract()) { uriList -> - scope.launch { - if (uriList.isNotEmpty()) { - val uris = uriList.map { it.toUri() } - val vault = currentVaultValue ?: return@launch - when (vaultEncryptBehavior) { - Settings.Vault.ENCRYPT_DELETE -> { - toAddMedia = uris - encryptAndRequestDeletion(vault, uris) - } - - Settings.Vault.ENCRYPT_KEEP -> { - toAddMedia = uris - addMediaKeepOriginals(vault, uris) - } - - else -> { - pickedUris = uris - addToVaultSheetState.show() - } - } - } - } - } - val postEncryptLauncher = rememberActivityResult() - - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(userMessage) { - userMessage?.collect { message -> - snackbarHostState.showSnackbar(message) - } - } - - val context = androidx.compose.ui.platform.LocalContext.current - val passwordSetupSheetState = rememberAppBottomSheetState() - var hasCustomPassword by remember { mutableStateOf(false) } - val passwordSetMsg = stringResource(R.string.vault_password_set) - val passwordRemovedMsg = stringResource(R.string.vault_password_removed) - LaunchedEffect(currentVaultValue) { - val uuid = currentVaultValue?.uuid ?: return@LaunchedEffect - hasCustomPassword = VaultPasswordManager.hasCustomPassword(context, uuid) - } - val removePasswordSheetState = rememberAppBottomSheetState() - RemovePasswordSheet( - state = removePasswordSheetState, - onConfirm = { - val uuid = currentVaultValue?.uuid ?: return@RemovePasswordSheet - scope.launch { - VaultPasswordManager.removePassword(context, uuid) - hasCustomPassword = false - snackbarHostState.showSnackbar(passwordRemovedMsg) - } - } - ) - VaultPasswordSetupSheet( - state = passwordSetupSheetState, - onSecretSet = { type, secret -> - val uuid = currentVaultValue?.uuid ?: return@VaultPasswordSetupSheet - scope.launch { - VaultPasswordManager.setPassword(context, uuid, secret, type) - hasCustomPassword = true - snackbarHostState.showSnackbar(passwordSetMsg) - } - } - ) - - LaunchedEffect(Unit) { - pendingDeletions.collect { leftovers -> - if (leftovers.isNotEmpty()) { - deleteLeftovers(postEncryptLauncher, leftovers) - toAddMedia = emptyList() - bottomSheetState.hide() - } - } - } - - val newVaultSheetState = rememberAppBottomSheetState() - val decryptVaultSheetState = rememberAppBottomSheetState() - val deleteVaultSheetState = rememberAppBottomSheetState() - val actionsSheetState = rememberAppBottomSheetState() - - LaunchedEffect(vaultState.value) { - if (vaultState.value.isLoading) return@LaunchedEffect - if (vaultState.value.vaults.isNotEmpty()) return@LaunchedEffect - globalNavigateUp() - } - - LaunchedEffect(isRunning) { - if (isRunning) { - bottomSheetState.show() - } else { - bottomSheetState.hide() - } - } - - ModalSheet( - sheetState = bottomSheetState, - onDismissRequest = {}, - title = stringResource(R.string.encrypting_media), - content = { - Box( - modifier = Modifier - .padding(32.dp) - .align(Alignment.CenterHorizontally) - .padding(bottom = 48.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - progress = { - progress.fastCoerceAtLeast(0f) - }, - strokeWidth = 4.dp, - strokeCap = StrokeCap.Round, - modifier = Modifier.size(128.dp), - ) - Text(text = "${progress.roundToInt()}%") - } - - SetupButton( - onClick = { - scope.launch { - val indexesToDrop = (progress * toAddMedia.size / 100).roundToInt() - toAddMedia = toAddMedia.dropLast(indexesToDrop) - bottomSheetState.hide() - } - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.action_cancel) - ) - } - ) - - val selector = LocalMediaSelector.current - val selectionState = selector.isSelectionActive.collectAsStateWithLifecycle() - val selectedMediaSet = selector.selectedMedia.collectAsStateWithLifecycle() - val selectedMediaList by selectedMedia( - media = mediaState.value.media, - selectedSet = selectedMediaSet - ) - - Box( - modifier = Modifier - ) { - Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), - snackbarHost = { SnackbarHost(snackbarHostState) }, - floatingActionButton = { - AnimatedVisibility( - visible = !selectionState.value, - enter = enterAnimation, - exit = exitAnimation - ) { - FloatingActionButton( - onClick = { - pickerLauncher.launch(Unit) - } - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.add_media_to_vault_cd) - ) - } - } - }, - topBar = { - TopAppBar( - title = { - Column { - val sheetState = rememberAppBottomSheetState() - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .clickable( - enabled = remember(vaultState.value) { - vaultState.value.vaults.size > 1 - }, - ) { - scope.launch { - sheetState.show() - } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = currentVaultValue?.name - ?: stringResource(R.string.unknown_vault) - ) - AnimatedVisibility( - visible = vaultState.value.vaults.size > 1, - enter = enterAnimation, - exit = exitAnimation - ) { - Icon( - modifier = Modifier - .size(24.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape - ), - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = stringResource(R.string.switch_vault_cd), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - SelectVaultSheet( - state = sheetState, - vaultState = vaultState.value, - excludeVault = currentVaultValue - ) { vault -> - onAuthenticateVault(vault) - } - } - }, - navigationIcon = { - NavigationBackButton( - forcedAction = globalNavigateUp, - ) - }, - actions = { - IconButton(onClick = { - scope.launch { actionsSheetState.show() } - }) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.more_options) - ) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { scaffoldPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .hazeSource(LocalHazeState.current) - ) { - GridPinchZoomLayout( - state = pinchState, - indicatorTopPadding = scaffoldPadding.calculateTopPadding() + 16.dp, - ) { - MediaGridView( - mediaState = mediaState, - metadataState = metadataState, - paddingValues = scaffoldPadding, - showSearchBar = false, - allowSelection = true, - canScroll = canScroll, - aboveGridContent = {}, - isScrolling = remember { mutableStateOf(false) }, - emptyContent = ::EmptyMedia, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope, - onMediaClick = { encryptedMedia -> - onMediaClick(encryptedMedia.id) - }, - ) - } - } - } - - SelectionSheet( - modifier = Modifier.align(Alignment.BottomEnd), - allMedia = mediaState.value, - selectedMedia = selectedMediaList, - isInVault = true, - currentVault = currentVaultValue, - ) - - AddToVaultSheet( - state = addToVaultSheetState, - onEncryptAndDelete = { - val vault = currentVaultValue ?: return@AddToVaultSheet - toAddMedia = pickedUris - encryptAndRequestDeletion(vault, pickedUris) - pickedUris = emptyList() - }, - onEncryptAndKeep = { - val vault = currentVaultValue ?: return@AddToVaultSheet - toAddMedia = pickedUris - addMediaKeepOriginals(vault, pickedUris) - pickedUris = emptyList() - }, - onBehaviorChanged = { vaultEncryptBehavior = it } - ) - - NewVaultSheet( - state = newVaultSheetState, - onConfirm = onCreateVaultClick - ) - DeleteVaultSheet( - state = deleteVaultSheetState, - vaultName = currentVaultValue?.name - ) { - val vault = currentVaultValue ?: vaultState.value.vaults.firstOrNull() - vault?.let { it1 -> deleteVault(it1) } - } - RestoreVaultSheet( - state = decryptVaultSheetState - ) { - val vault = currentVaultValue ?: vaultState.value.vaults.firstOrNull() - vault?.let { it1 -> restoreVault(it1) } - } - VaultActionsSheet( - state = actionsSheetState, - hasCustomPassword = hasCustomPassword, - onNewVault = { scope.launch { newVaultSheetState.show() } }, - onDecryptVault = { scope.launch { decryptVaultSheetState.show() } }, - onCustomPassword = { - if (hasCustomPassword) { - scope.launch { removePasswordSheetState.show() } - } else { - scope.launch { passwordSetupSheetState.show() } - } - }, - onDeleteVault = { scope.launch { deleteVaultSheetState.show() } } - ) - } - -} - -@Composable -private fun VaultActionsSheet( - state: AppBottomSheetState, - hasCustomPassword: Boolean, - onNewVault: () -> Unit, - onDecryptVault: () -> Unit, - onCustomPassword: () -> Unit, - onDeleteVault: () -> Unit, -) { - val scope = rememberCoroutineScope() - val newVaultLabel = stringResource(R.string.new_vault) - val decryptLabel = stringResource(R.string.decrypt_vault) - val customPasswordLabel = stringResource( - if (hasCustomPassword) R.string.vault_remove_custom_password - else R.string.vault_custom_password - ) - val deleteVaultLabel = stringResource(R.string.delete_vault) - - val options = remember( - hasCustomPassword, - newVaultLabel, - decryptLabel, - customPasswordLabel, - deleteVaultLabel - ) { - listOf( - OptionItem( - icon = Icons.Outlined.Add, - text = newVaultLabel, - onClick = { - scope.launch { state.hide() } - onNewVault() - } - ), - OptionItem( - icon = Icons.Outlined.Restore, - text = decryptLabel, - onClick = { - scope.launch { state.hide() } - onDecryptVault() - } - ), - OptionItem( - icon = if (hasCustomPassword) Icons.Outlined.Lock else Icons.Outlined.LockOpen, - text = customPasswordLabel, - onClick = { - scope.launch { state.hide() } - onCustomPassword() - } - ), - OptionItem( - icon = Icons.Default.DeleteOutline, - text = deleteVaultLabel, - onClick = { - scope.launch { state.hide() } - onDeleteVault() - } - ), - ).toMutableStateList() - } - - OptionSheet( - state = state, - headerContent = { - Text( - text = stringResource(R.string.vault_actions), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) - ) - }, - optionList = arrayOf(options) - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultGateSetupScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultGateSetupScreen.kt deleted file mode 100644 index 7ee6d277e3..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultGateSetupScreen.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.core.presentation.components.SetupWizard -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.vault.components.VaultPasswordSetupSheet -import com.dot.gallery.feature_node.presentation.vault.utils.GateMode -import com.dot.gallery.feature_node.presentation.vault.utils.VaultPasswordManager -import com.dot.gallery.ui.core.Icons -import com.dot.gallery.ui.core.icons.Encrypted -import kotlinx.coroutines.launch - -@Composable -fun VaultGateSetupScreen( - onBack: (() -> Unit)? = null, - onNone: () -> Unit, - onDeviceSecurity: () -> Unit, - onCustomComplete: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val customSetupSheetState = rememberAppBottomSheetState() - - Box(modifier = Modifier.fillMaxSize()) { - SetupWizard( - icon = Icons.Encrypted, - title = stringResource(R.string.vault_gate_setup_title), - subtitle = stringResource(R.string.vault_gate_setup_subtitle), - bottomBar = {}, - content = { - Text( - text = stringResource(R.string.vault_gate_setup_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - SetupButton( - onClick = { - scope.launch { - VaultPasswordManager.setGateMode(context, GateMode.NONE) - onNone() - } - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - text = stringResource(R.string.vault_gate_none) - ) - SetupButton( - onClick = { - scope.launch { - VaultPasswordManager.setGateMode(context, GateMode.DEVICE) - onDeviceSecurity() - } - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.vault_gate_device) - ) - SetupButton( - onClick = { - scope.launch { customSetupSheetState.show() } - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.vault_gate_custom) - ) - } - } - ) - - if (onBack != null) { - NavigationBackButton( - modifier = Modifier.statusBarsPadding(), - forcedAction = onBack - ) - } - } - - VaultPasswordSetupSheet( - state = customSetupSheetState, - onSecretSet = { type, secret -> - scope.launch { - VaultPasswordManager.setGateMode(context, GateMode.CUSTOM) - VaultPasswordManager.setPassword( - context, - VaultPasswordManager.GATE_UUID, - secret, - type - ) - onCustomComplete() - } - } - ) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultPasswordSetupScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultPasswordSetupScreen.kt deleted file mode 100644 index cd9c3f5a14..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultPasswordSetupScreen.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.core.presentation.components.SetupWizard -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import com.dot.gallery.feature_node.presentation.vault.components.VaultPasswordSetupSheet -import com.dot.gallery.feature_node.presentation.vault.utils.VaultPasswordManager -import com.dot.gallery.ui.core.Icons -import com.dot.gallery.ui.core.icons.Encrypted -import kotlinx.coroutines.launch - -@Composable -fun VaultPasswordSetupScreen( - vault: Vault?, - onSkip: () -> Unit, - onComplete: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val passwordSetupSheetState = rememberAppBottomSheetState() - - SetupWizard( - icon = Icons.Encrypted, - title = stringResource(R.string.vault_set_password), - subtitle = stringResource(R.string.vault_setup_password_prompt), - bottomBar = { - SetupButton( - onClick = onSkip, - modifier = Modifier.weight(1f), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - text = stringResource(R.string.vault_skip_password) - ) - SetupButton( - onClick = { - scope.launch { passwordSetupSheetState.show() } - }, - modifier = Modifier.weight(1f), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.vault_set_password) - ) - }, - content = { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.vault_custom_password_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } - } - ) - - VaultPasswordSetupSheet( - state = passwordSetupSheetState, - onSecretSet = { type, secret -> - if (vault != null) { - scope.launch { - VaultPasswordManager.setPassword(context, vault.uuid, secret, type) - onComplete() - } - } - } - ) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt deleted file mode 100644 index f3e21d1660..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt +++ /dev/null @@ -1,553 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import android.graphics.Color -import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute -import com.dot.gallery.R -import com.dot.gallery.core.Constants.Animation.enterAnimation -import com.dot.gallery.core.Constants.Animation.exitAnimation -import com.dot.gallery.core.Constants.Animation.navigateInAnimation -import com.dot.gallery.core.Constants.Animation.navigateUpAnimation -import com.dot.gallery.core.DefaultEventHandler -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.Settings.Misc.rememberForceTheme -import com.dot.gallery.core.Settings.Misc.rememberIsDarkMode -import com.dot.gallery.core.navigateUp -import com.dot.gallery.feature_node.domain.model.UIEvent -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreenRoute -import com.dot.gallery.feature_node.presentation.util.SecureWindow -import com.dot.gallery.feature_node.presentation.vault.components.VaultPasswordUnlockDialog -import com.dot.gallery.feature_node.presentation.vault.utils.GateMode -import com.dot.gallery.feature_node.presentation.vault.utils.VaultAuthType -import com.dot.gallery.feature_node.presentation.vault.utils.VaultPasswordManager -import com.dot.gallery.feature_node.presentation.vault.utils.VerifyResult -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun VaultScreen( - paddingValues: PaddingValues, - toggleRotate: () -> Unit, - shouldSkipAuth: MutableState -) = SecureWindow { - val globalEventHandler = LocalEventHandler.current - val viewModel = hiltViewModel() - val navController = rememberNavController() - var addNewVault by remember { mutableStateOf(false) } - - val localEventHandler = remember { DefaultEventHandler() } - LaunchedEffect(localEventHandler) { - localEventHandler.navigateAction = { - navController.navigate(it) { - launchSingleTop = true - restoreState = true - } - } - localEventHandler.navigateUpAction = navController::navigateUp - } - LaunchedEffect(localEventHandler) { - localEventHandler.updaterFlow.collectLatest { event -> - when (event) { - is UIEvent.NavigationRouteEvent -> localEventHandler.navigateAction(event.route) - UIEvent.NavigationUpEvent -> localEventHandler.navigateUpAction() - is UIEvent.SetFollowThemeEvent -> globalEventHandler.setFollowThemeAction(event.followTheme) - is UIEvent.ToggleNavigationBarEvent -> globalEventHandler.toggleNavigationBarAction( - event.isVisible - ) - - UIEvent.UpdateDatabase -> {} - } - } - } - CompositionLocalProvider( - LocalEventHandler provides localEventHandler - ) { - val context = LocalContext.current - val albumState = viewModel.albumsState.collectAsStateWithLifecycle() - val metadataState = viewModel.metadataState.collectAsStateWithLifecycle() - val vaultState = viewModel.vaultState.collectAsStateWithLifecycle() - - var isAuthenticated by rememberSaveable { mutableStateOf(shouldSkipAuth.value) } - var isGateAuthenticated by rememberSaveable { mutableStateOf(shouldSkipAuth.value) } - - // Per-vault auth state - var showPasswordDialog by remember { mutableStateOf(false) } - var passwordError by remember { mutableStateOf(null) } - var detectedAuthType by remember { mutableStateOf(null) } - var pendingAuthVault by rememberSaveable { mutableStateOf(null) } - - // Gate auth state - var showGatePasswordDialog by remember { mutableStateOf(false) } - var gatePasswordError by remember { mutableStateOf(null) } - var gateAuthType by remember { mutableStateOf(null) } - - val wrongPasswordAttemptsStr = stringResource(R.string.vault_wrong_password_attempts) - val lockedOutStr = stringResource(R.string.vault_locked_out) - val scope = rememberCoroutineScope() - - /** Navigate to VaultDisplay after successful per-vault auth */ - fun onVaultAuthSuccess(vault: Vault) { - isAuthenticated = true - viewModel.currentVault.value = vault - viewModel.setVault( - vault = vault, - onFailed = { println("Vault set failed: $it") }, - onSuccess = { println("Vault switched to ${vault.name}") } - ) - val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute?.contains("VaultDisplay") != true) { - navController.navigate(VaultScreens.VaultDisplay) { - popUpTo { inclusive = false } - launchSingleTop = true - } - } - } - - // Per-vault biometric - val biometricState = rememberBiometricState( - title = stringResource(R.string.biometric_authentication), - subtitle = stringResource(R.string.verify_identity), - onSuccess = { - pendingAuthVault?.let { onVaultAuthSuccess(it) } - pendingAuthVault = null - }, - onFailed = { pendingAuthVault = null } - ) - - /** Authenticate a specific vault: custom password dialog or device biometric. */ - fun authenticateVault(vault: Vault) { - scope.launch { - pendingAuthVault = vault - val authType = VaultPasswordManager.getAuthType(context, vault.uuid) - if (authType != null) { - detectedAuthType = authType - showPasswordDialog = true - } else if (biometricState.isSupported) { - detectedAuthType = null - biometricState.authenticate() - } else { - onVaultAuthSuccess(vault) - pendingAuthVault = null - detectedAuthType = null - } - } - } - - fun onGateAuthSuccess() { - isGateAuthenticated = true - showGatePasswordDialog = false - gatePasswordError = null - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } - - // Gate biometric - val gateBiometricState = rememberBiometricState( - title = stringResource(R.string.biometric_authentication), - subtitle = stringResource(R.string.verify_identity), - onSuccess = { onGateAuthSuccess() }, - onFailed = { globalEventHandler.navigateUp() } - ) - - /** Trigger gate authentication based on stored GateMode */ - fun triggerGateAuth() { - scope.launch { - when (VaultPasswordManager.getGateMode(context)) { - GateMode.NONE -> onGateAuthSuccess() - GateMode.DEVICE -> { - if (gateBiometricState.isSupported) { - gateBiometricState.authenticate() - } else { - onGateAuthSuccess() - } - } - GateMode.CUSTOM -> { - val authType = VaultPasswordManager.getAuthType( - context, VaultPasswordManager.GATE_UUID - ) - if (authType != null) { - gateAuthType = authType - showGatePasswordDialog = true - } else { - // Custom auth was set but credentials are missing — fallback - onGateAuthSuccess() - } - } - } - } - } - - val navBackStackEntry by navController.currentBackStackEntryAsState() - val systemBarFollowThemeState = rememberSaveable(navBackStackEntry) { - mutableStateOf( - navBackStackEntry?.destination?.route?.contains("EncryptedMediaViewScreen") == false - ) - } - val forcedTheme by rememberForceTheme() - val localDarkTheme by rememberIsDarkMode() - val systemDarkTheme = isSystemInDarkTheme() - val darkTheme by remember(forcedTheme, localDarkTheme, systemDarkTheme) { - mutableStateOf(if (forcedTheme) localDarkTheme else systemDarkTheme) - } - LaunchedEffect(darkTheme, systemBarFollowThemeState.value) { - (context as? ComponentActivity)?.enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto( - Color.TRANSPARENT, - Color.TRANSPARENT, - ) { darkTheme || !systemBarFollowThemeState.value }, - navigationBarStyle = SystemBarStyle.auto( - Color.TRANSPARENT, - Color.TRANSPARENT, - ) { darkTheme || !systemBarFollowThemeState.value } - ) - } - - SharedTransitionLayout { - NavHost( - modifier = Modifier.fillMaxSize(), - navController = navController, - startDestination = VaultScreens.LoadingScreen, - enterTransition = { navigateInAnimation }, - exitTransition = { navigateUpAnimation }, - popEnterTransition = { navigateInAnimation }, - popExitTransition = { navigateUpAnimation } - ) { - composable { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - // One-time navigation when loading finishes - LaunchedEffect(vaultState.value.isLoading) { - if (!vaultState.value.isLoading) { - val dest = if (vaultState.value.vaults.isEmpty()) { - VaultScreens.VaultSetup - } else { - VaultScreens.VaultGateAuth - } - navController.navigate(dest) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } - } - } - - composable { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - LaunchedEffect(Unit) { - if (isGateAuthenticated) { - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } else { - triggerGateAuth() - } - } - } - - composable { - VaultSelectScreen( - vaultState = vaultState, - onVaultSelected = { vault -> authenticateVault(vault) }, - onCreateVault = { - addNewVault = true - navController.navigate(VaultScreens.VaultSetup) { - launchSingleTop = true - } - }, - onChangeGateSecurity = { - navController.navigate(VaultScreens.VaultGateSetup) { - launchSingleTop = true - } - }, - onNavigateUp = globalEventHandler::navigateUp - ) - } - - composable { - VaultSetup( - navigateUp = { - addNewVault = false - if (vaultState.value.vaults.isEmpty()) globalEventHandler.navigateUp() else localEventHandler.navigateUp() - }, - onCreate = { - navController.navigate(VaultScreens.VaultPasswordSetup) { - popUpTo { inclusive = true } - launchSingleTop = true - } - }, - vm = viewModel - ) - } - composable { - val currentVaultValue by viewModel.currentVault.collectAsStateWithLifecycle() - val isFirstVault = vaultState.value.vaults.size <= 1 - - fun afterPasswordSetup() { - addNewVault = false - if (isFirstVault) { - // First vault → show gate security setup - navController.navigate(VaultScreens.VaultGateSetup) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } else { - // Subsequent vaults → go to vault selector - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } - } - - VaultPasswordSetupScreen( - vault = currentVaultValue, - onSkip = { afterPasswordSetup() }, - onComplete = { afterPasswordSetup() } - ) - } - composable { - fun afterGateSetup() { - isGateAuthenticated = true - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } - - VaultGateSetupScreen( - onBack = if (navController.previousBackStackEntry != null) { - { navController.popBackStack() } - } else null, - onNone = { afterGateSetup() }, - onDeviceSecurity = { afterGateSetup() }, - onCustomComplete = { afterGateSetup() } - ) - } - composable { - // If user reaches VaultDisplay without being authenticated - // (e.g., back navigation), send them back to VaultSelect - LaunchedEffect(isAuthenticated, vaultState) { - if (!isAuthenticated && !addNewVault && vaultState.value.vaults.isNotEmpty()) { - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - } - } - AnimatedVisibility( - visible = isAuthenticated, - enter = enterAnimation, - exit = exitAnimation - ) { - VaultDisplay( - globalNavigateUp = { - // When leaving VaultDisplay, revoke authentication - isAuthenticated = false - globalEventHandler.navigateUp() - }, - vaultState = vaultState, - currentVault = viewModel.currentVault, - createMediaState = viewModel::createMediaState, - deleteLeftovers = viewModel::deleteLeftovers, - deleteVault = viewModel::deleteVault, - setVault = { vault -> viewModel.setVault(vault) {} }, - onCreateVaultClick = { - addNewVault = true - navController.navigate(VaultScreens.VaultSetup) { - launchSingleTop = true - } - }, - onAuthenticateVault = { vault -> - authenticateVault(vault) - }, - onVaultDeleted = { - isAuthenticated = false - navController.navigate(VaultScreens.VaultSelect) { - popUpTo { inclusive = true } - launchSingleTop = true - } - }, - restoreVault = viewModel::restoreVault, - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this@composable, - workerProgress = viewModel.progress, - workerIsRunning = viewModel.isRunning, - metadataState = metadataState, - encryptAndRequestDeletion = viewModel::encryptAndRequestDeletion, - addMediaKeepOriginals = viewModel::addMediaKeepOriginals, - pendingDeletions = viewModel.pendingDeletions, - userMessage = viewModel.userMessage, - onMediaClick = { mediaId -> - navController.navigate(VaultScreens.EncryptedMediaViewScreen(mediaId)) - }, - ) - } - } - - composable { backStackEntry -> - val args = backStackEntry.toRoute() - val mediaId = args.mediaId - val currentVaultValue by viewModel.currentVault.collectAsStateWithLifecycle() - val mediaState = remember(currentVaultValue) { - viewModel.createMediaState(currentVaultValue) - }.collectAsStateWithLifecycle() - MediaViewScreenRoute( - toggleRotate = toggleRotate, - paddingValues = paddingValues, - mediaId = mediaId, - mediaState = mediaState, - vaultState = vaultState, - albumsState = albumState, - metadataState = metadataState, - currentVault = currentVaultValue, - restoreMedia = viewModel::restoreMedia, - deleteMedia = viewModel::deleteMedia, - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this@composable - ) - } - } - } - - // Gate password/PIN/pattern dialog - AnimatedVisibility( - visible = showGatePasswordDialog, - enter = enterAnimation, - exit = exitAnimation - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - ) { - VaultPasswordUnlockDialog( - authType = gateAuthType, - onDismiss = { - showGatePasswordDialog = false - gatePasswordError = null - globalEventHandler.navigateUp() - }, - onSubmit = { secret -> - scope.launch { - when (val result = VaultPasswordManager.verifyPassword( - context, - VaultPasswordManager.GATE_UUID, - secret - )) { - is VerifyResult.Success -> onGateAuthSuccess() - - is VerifyResult.Failed -> { - gatePasswordError = String.format( - wrongPasswordAttemptsStr, - result.attemptsLeft - ) - } - - is VerifyResult.LockedOut -> { - val seconds = (result.cooldownMs / 1000).coerceAtLeast(1) - gatePasswordError = String.format(lockedOutStr, seconds) - } - } - } - }, - errorMessage = gatePasswordError - ) - } - } - - // Per-vault password/PIN/pattern dialog - AnimatedVisibility( - visible = showPasswordDialog, - enter = enterAnimation, - exit = exitAnimation - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - ) { - VaultPasswordUnlockDialog( - authType = detectedAuthType, - onDismiss = { - showPasswordDialog = false - passwordError = null - pendingAuthVault = null - }, - onSubmit = { secret -> - val vault = pendingAuthVault - if (vault != null) { - scope.launch { - when (val result = VaultPasswordManager.verifyPassword( - context, - vault.uuid, - secret - )) { - is VerifyResult.Success -> { - showPasswordDialog = false - passwordError = null - onVaultAuthSuccess(vault) - pendingAuthVault = null - } - - is VerifyResult.Failed -> { - passwordError = String.format( - wrongPasswordAttemptsStr, - result.attemptsLeft - ) - } - - is VerifyResult.LockedOut -> { - val seconds = (result.cooldownMs / 1000).coerceAtLeast(1) - passwordError = String.format(lockedOutStr, seconds) - } - } - } - } - }, - errorMessage = passwordError - ) - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt deleted file mode 100644 index d86914885e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import kotlinx.serialization.Serializable - -@Serializable -sealed class VaultScreens { - - @Serializable - data object VaultSetup : VaultScreens() - - @Serializable - data object VaultPasswordSetup : VaultScreens() - - @Serializable - data object VaultGateAuth : VaultScreens() - - @Serializable - data object VaultGateSetup : VaultScreens() - - @Serializable - data object VaultSelect : VaultScreens() - - @Serializable - data object VaultDisplay : VaultScreens() - - @Serializable - data class EncryptedMediaViewScreen(val mediaId: Long) : VaultScreens() - - @Serializable - data object LoadingScreen : VaultScreens() -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSelectScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSelectScreen.kt deleted file mode 100644 index 98edaa987b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSelectScreen.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.vault - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons as MaterialIcons -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.ui.core.Icons as GalleryIcons -import com.dot.gallery.ui.core.icons.Encrypted - -@Composable -fun VaultSelectScreen( - vaultState: State, - onVaultSelected: (Vault) -> Unit, - onCreateVault: () -> Unit, - onChangeGateSecurity: () -> Unit, - onNavigateUp: () -> Unit, -) { - val vaults = vaultState.value.vaults - - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - NavigationBackButton( - forcedAction = onNavigateUp - ) - IconButton( - onClick = onChangeGateSecurity, - modifier = Modifier - .padding(horizontal = 8.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = CircleShape - ) - ) { - Icon( - imageVector = MaterialIcons.Outlined.Settings, - contentDescription = stringResource(R.string.vault_manage_security), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Icon( - imageVector = GalleryIcons.Encrypted, - contentDescription = stringResource(R.string.vault_icon_cd), - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(R.string.select_a_vault), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.vault_select_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - - Spacer(modifier = Modifier.height(32.dp)) - - LazyColumn( - modifier = Modifier - .widthIn(max = 600.dp) - .fillMaxWidth() - .padding(horizontal = 24.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - items(vaults, key = { it.uuid }) { vault -> - VaultCard( - vault = vault, - onClick = { onVaultSelected(vault) } - ) - } - item(key = "create_new") { - CreateVaultCard(onClick = onCreateVault) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun VaultCard( - vault: Vault, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable(onClick = onClick) - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = RoundedCornerShape(12.dp) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.Outlined.Lock, - contentDescription = stringResource(R.string.vault_locked_cd), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp) - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = vault.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} - -@Composable -private fun CreateVaultCard( - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .clickable(onClick = onClick) - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(12.dp) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = MaterialIcons.Outlined.Add, - contentDescription = stringResource(R.string.create_vault_cd), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(24.dp) - ) - } - - Text( - text = stringResource(R.string.new_vault), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt deleted file mode 100644 index f084d11115..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import android.app.KeyguardManager -import android.os.Build -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS -import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricManager -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import com.dot.gallery.core.presentation.components.SetupButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.SetupWizard -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.presentation.util.printError -import com.dot.gallery.ui.core.Icons -import com.dot.gallery.ui.core.icons.Encrypted - -@Composable -fun VaultSetup( - navigateUp: () -> Unit, - onCreate: () -> Unit, - vm: VaultViewModel -) { - val context = LocalContext.current - - var nameError by remember { mutableStateOf("") } - var newVault by remember { mutableStateOf(Vault(name = "")) } - - val biometricManager = rememberBiometricManager() - val isBiometricAvailable = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS - } else { - val keyguardManager = context.getSystemService(KeyguardManager::class.java) - keyguardManager?.isDeviceSecure == true - } - } - SetupWizard( - icon = Icons.Encrypted, - title = stringResource(R.string.vault_setup_title), - subtitle = stringResource(R.string.vault_setup_subtitle), - bottomBar = { - SetupButton( - onClick = navigateUp, - modifier = Modifier.weight(1f), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - text = stringResource(id = R.string.action_cancel) - ) - SetupButton( - onClick = { - vm.setVault( - vault = newVault, - onFailed = { - val newError = if (it.contains("Already exists")) { - context.getString(R.string.vault_already_exists, newVault.name) - } else it - printError("Error: $newError") - nameError = newError - }, - onSuccess = { - onCreate() - } - ) - }, - enabled = isBiometricAvailable && nameError.isEmpty() && newVault.name.isNotEmpty(), - modifier = Modifier.weight(1f), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(id = R.string.get_started) - ) - }, - content = { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = newVault.name, - onValueChange = { newName -> - nameError = "" - newVault = newVault.copy(name = newName.filter { it.isLetterOrDigit() }) - }, - label = { Text(text = stringResource(R.string.vault_setup_name)) }, - singleLine = true, - isError = nameError.isNotEmpty(), - enabled = isBiometricAvailable - ) - - AnimatedVisibility(visible = !isBiometricAvailable) { - Text( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - text = stringResource(R.string.vault_setup_security_error), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - - } - - AnimatedVisibility(visible = isBiometricAvailable) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - text = stringResource(R.string.vault_setup_summary2), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Text( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - text = stringResource(R.string.vault_setup_decryption_info), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - textAlign = TextAlign.Center - ) - } - } - - AnimatedVisibility(visible = nameError.isNotEmpty()) { - Text( - text = nameError, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error - ) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt deleted file mode 100644 index 79cf2ed46c..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault - -import android.content.Context -import android.net.Uri -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.core.net.toUri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.dot.gallery.core.Constants -import com.dot.gallery.core.MediaDistributor -import com.dot.gallery.R -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.core.Settings -import com.dot.gallery.core.workers.VaultOperationWorker -import com.dot.gallery.core.workers.enqueueVaultOperation -import com.dot.gallery.core.workers.enqueueVaultOperationWithId -import com.dot.gallery.feature_node.domain.model.AlbumState -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Media.UriMedia -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.model.MediaState -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.presentation.util.mapMedia -import com.dot.gallery.feature_node.presentation.util.printError -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.serialization.json.Json -import android.os.Environment -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.UUID -import javax.inject.Inject - -@OptIn(FlowPreview::class) -@HiltViewModel -open class VaultViewModel @Inject constructor( - private val repository: MediaRepository, - distributor: MediaDistributor, - private val workManager: WorkManager, - database: InternalDatabase, - @param:ApplicationContext private val appContext: Context -) : ViewModel() { - - private val defaultDateFormat = - repository.getSetting(Settings.Misc.DEFAULT_DATE_FORMAT, Constants.DEFAULT_DATE_FORMAT) - .stateIn(viewModelScope, SharingStarted.Eagerly, Constants.DEFAULT_DATE_FORMAT) - - private val extendedDateFormat = - repository.getSetting(Settings.Misc.EXTENDED_DATE_FORMAT, Constants.EXTENDED_DATE_FORMAT) - .stateIn(viewModelScope, SharingStarted.Eagerly, Constants.EXTENDED_DATE_FORMAT) - - private val weeklyDateFormat = - repository.getSetting(Settings.Misc.WEEKLY_DATE_FORMAT, Constants.WEEKLY_DATE_FORMAT) - .stateIn(viewModelScope, SharingStarted.Eagerly, Constants.WEEKLY_DATE_FORMAT) - - val currentVault = MutableStateFlow(null) - - // User-facing message flow for snackbar / toast feedback (multicast, no replay) - private val _userMessage = MutableSharedFlow(extraBufferCapacity = 10) - val userMessage: SharedFlow = _userMessage - - // Emits lists of original URIs that should be deleted (user permission required) after a - // vault operation (encrypt/hide) succeeds with deleteOriginals=true. - private val _pendingDeletions = MutableSharedFlow>(extraBufferCapacity = 10) - val pendingDeletions: SharedFlow> = _pendingDeletions - - val vaultState = distributor.vaultsMediaFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, VaultState()) - - val isRunning = workManager.getWorkInfosByTagFlow("VaultOp") - .map { it.lastOrNull()?.state == WorkInfo.State.RUNNING } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - val progress = workManager.getWorkInfosByTagFlow("VaultOp") - .map { - it.lastOrNull()?.progress?.getFloat(VaultOperationWorker.KEY_PROGRESS, 0f) ?: 0f - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0f) - - val vaultItemCounts = database.getVaultDao().getMediaCountPerVault() - .map { counts -> counts.associate { it.uuid to it.count } } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) - - val metadataState = distributor.metadataFlow.stateIn( - viewModelScope, - started = SharingStarted.Eagerly, - MediaMetadataState() - ) - val albumsState = distributor.albumsFlow.stateIn( - viewModelScope, - started = SharingStarted.Eagerly, - AlbumState() - ) - - fun createMediaState(vault: Vault?) = repository.getEncryptedMedia(vault) - .debounce(300) - .mapMedia( - albumId = -1, - updateDatabase = {}, - defaultDateFormat = defaultDateFormat.value, - extendedDateFormat = extendedDateFormat.value, - weeklyDateFormat = weeklyDateFormat.value - ) - .stateIn(viewModelScope, SharingStarted.Eagerly, MediaState()) - - fun setVault(vault: Vault?, transferable: Boolean = false, onFailed: (reason: String) -> Unit = {}, onSuccess: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - currentVault.value = vault - if (vault == null) { - withContext(Dispatchers.Main.immediate) { onSuccess() } - return@launch - } - val hasVault = vaultState.value.vaults.find { it.uuid == vault.uuid } != null - if (hasVault) { - withContext(Dispatchers.Main.immediate) { onSuccess() } - } else { - if (vaultState.value.vaults.firstOrNull { it.name == vault.name } != null) { - onFailed("Already exists") - return@launch - } - repository.createVault( - vault = vault, - transferable = transferable, - onSuccess = { - currentVault.value = vault - viewModelScope.launch(Dispatchers.Main.immediate) { onSuccess() } - }, - onFailed = onFailed - ) - } - } - } - - fun deleteVault(vault: Vault) { - viewModelScope.launch(Dispatchers.IO) { - repository.deleteVault( - vault = vault, - onSuccess = { - currentVault.value = null - }, - onFailed = { - printError("Failed to delete vault: $it") - } - ) - } - } - - fun addMedia(vault: Vault, list: List) { - workManager.enqueueVaultOperation( - operation = VaultOperationWorker.OP_ENCRYPT, - media = list, - vault = vault, - uniqueKey = vault.uuid.toString() - ) - } - - /** Encrypt into vault without deleting originals, and show feedback on completion. */ - fun addMediaKeepOriginals(vault: Vault, uris: List) { - val id = workManager.enqueueVaultOperationWithId( - operation = VaultOperationWorker.OP_ENCRYPT, - media = uris, - vault = vault, - uniqueKey = "encrypt_keep_${vault.uuid}_${System.currentTimeMillis()}", - deleteOriginals = false - ) - viewModelScope.launch(Dispatchers.IO) { - val info = workManager.getWorkInfoByIdFlow(id) - .filter { it?.state?.isFinished == true } - .first() - when (info?.state) { - WorkInfo.State.SUCCEEDED -> { - _userMessage.emit( - appContext.getString(R.string.vault_items_encrypted, uris.size) - ) - } - WorkInfo.State.FAILED -> { - _userMessage.emit( - appContext.getString(R.string.vault_encrypt_failed, uris.size) - ) - } - else -> { /* ignore */ } - } - } - } - - /** Start an encrypt operation that will request original deletion on success. */ - fun encryptAndRequestDeletion(vault: Vault, uris: List) { - val id = workManager.enqueueVaultOperationWithId( - operation = VaultOperationWorker.OP_ENCRYPT, - media = uris, - vault = vault, - uniqueKey = "encrypt_${vault.uuid}_${System.currentTimeMillis()}", - deleteOriginals = true - ) - observeDeletionOutput(id, uris.size) - } - - /** Start a hide operation (single media) which after success requests deletion of original. */ - fun hideAndRequestDeletion(vault: Vault, uri: Uri) { - val id = workManager.enqueueVaultOperationWithId( - operation = VaultOperationWorker.OP_HIDE, - media = listOf(uri), - vault = vault, - uniqueKey = "hide_${vault.uuid}_${System.currentTimeMillis()}", - deleteOriginals = true - ) - observeDeletionOutput(id) - } - - private fun observeDeletionOutput(id: UUID, itemCount: Int = 0) { - viewModelScope.launch(Dispatchers.IO) { - val info = workManager.getWorkInfoByIdFlow(id) - .filter { it?.state?.isFinished == true } - .first() - when (info?.state) { - WorkInfo.State.SUCCEEDED -> { - val leftoversJson = info.outputData.getString(VaultOperationWorker.KEY_LEFTOVER_URIS) - val leftovers = leftoversJson?.let { - Json.decodeFromString>(it).map { s -> s.toUri() } - }.orEmpty() - if (leftovers.isNotEmpty()) { - _pendingDeletions.emit(leftovers) - } - _userMessage.emit( - appContext.getString(R.string.vault_items_encrypted, itemCount.coerceAtLeast(leftovers.size)) - ) - } - WorkInfo.State.FAILED -> { - _userMessage.emit( - appContext.getString(R.string.vault_encrypt_failed, itemCount) - ) - } - else -> { /* CANCELLED — ignore */ } - } - } - } - - fun restoreMedia(vault: Vault, media: UriMedia, onSuccess: () -> Unit) { - val id = workManager.enqueueVaultOperationWithId( - operation = VaultOperationWorker.OP_DECRYPT, - media = listOf(media.uri), - vault = vault, - uniqueKey = "restore_${vault.uuid}_${media.id}_${System.currentTimeMillis()}" - ) - viewModelScope.launch(Dispatchers.IO) { - val info = workManager.getWorkInfoByIdFlow(id) - .filter { it?.state?.isFinished == true } - .first() - if (info?.state == WorkInfo.State.SUCCEEDED) { - val restoredPath = if (media.mimeType.startsWith("video")) - Environment.DIRECTORY_MOVIES + "/Restored" - else - Environment.DIRECTORY_PICTURES + "/Restored" - _userMessage.emit( - appContext.getString(R.string.vault_restored_to, 1, restoredPath) - ) - withContext(Dispatchers.Main) { onSuccess() } - } - } - } - - suspend fun transferMedia(sourceVault: Vault, targetVault: Vault, media: UriMedia, copy: Boolean): Boolean { - return withContext(Dispatchers.IO) { - repository.transferMedia(sourceVault, targetVault, media, copy) - } - } - - fun deleteMedia(vault: Vault, media: UriMedia, onSuccess: () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - repository.deleteEncryptedMedia(vault, media) - _userMessage.emit(appContext.getString(R.string.vault_item_deleted)) - withContext(Dispatchers.Main) { onSuccess() } - } - } - - fun deleteAllMedia(vault: Vault) { - viewModelScope.launch(Dispatchers.IO) { - repository.deleteAllEncryptedMedia( - vault = vault, - onSuccess = { - }, - onFailed = { failedFiles -> - printError("Failed to delete files: $failedFiles") - // TODO: Handle failed files - } - ) - } - } - - fun restoreVault(vault: Vault) { - viewModelScope.launch(Dispatchers.IO) { - repository.restoreVault(vault) - } - } - - fun deleteLeftovers(result: ActivityResultLauncher, uris: List) { - if (uris.isEmpty()) return - viewModelScope.launch(Dispatchers.IO) { - val mediaList = uris.mapNotNull { Media.createFromUri(appContext, it) } - if (mediaList.isNotEmpty()) { - repository.deleteMedia(result, mediaList) - } - } - } - - fun importPortableVault(vault: Vault, base64Key: String, force: Boolean = false, onResult: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val success = repository.importPortableVault(vault, base64Key, force) - withContext(Dispatchers.Main) { onResult(success) } - } - } - - fun migrateVaultToPortable(vault: Vault, onProgress: (Int, Int) -> Unit = { _, _ -> }, onResult: (Boolean) -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - val success = repository.migrateVaultToPortable(vault, onProgress) - withContext(Dispatchers.Main) { onResult(success) } - } - } - - private fun Resource>?.mapToVaultState(): VaultState { - return VaultState( - isLoading = false, - vaults = (this?.data) ?: emptyList() - ) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/AddToVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/AddToVaultSheet.kt deleted file mode 100644 index 726abf0a9e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/AddToVaultSheet.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.Settings -import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddToVaultSheet( - state: AppBottomSheetState, - onEncryptAndDelete: () -> Unit, - onEncryptAndKeep: () -> Unit, - onBehaviorChanged: (String) -> Unit, -) { - val scope = rememberCoroutineScope() - var rememberChoice by remember { mutableStateOf(false) } - - if (state.isVisible) { - ModalBottomSheet( - sheetState = state.sheetState, - onDismissRequest = { - scope.launch { state.hide() } - rememberChoice = false - }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp, vertical = 16.dp) - ) { - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurface, - fontStyle = MaterialTheme.typography.titleLarge.fontStyle, - fontSize = MaterialTheme.typography.titleLarge.fontSize, - letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing - ) - ) { - append(stringResource(R.string.vault_add_to_vault_title)) - } - }, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(bottom = 16.dp) - .fillMaxWidth() - ) - Text( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - text = stringResource(R.string.vault_delete_originals), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = rememberChoice, - onCheckedChange = { rememberChoice = it } - ) - Text( - text = stringResource(R.string.vault_remember_choice), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - SetupButton( - onClick = { - if (rememberChoice) { - onBehaviorChanged(Settings.Vault.ENCRYPT_KEEP) - } - onEncryptAndKeep() - scope.launch { state.hide() } - rememberChoice = false - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = stringResource(R.string.vault_encrypt_and_keep) - ) - SetupButton( - onClick = { - if (rememberChoice) { - onBehaviorChanged(Settings.Vault.ENCRYPT_DELETE) - } - onEncryptAndDelete() - scope.launch { state.hide() } - rememberChoice = false - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = stringResource(R.string.vault_encrypt_and_delete) - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/ConfirmationSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/ConfirmationSheet.kt deleted file mode 100644 index b871d9f8ec..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/ConfirmationSheet.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import com.dot.gallery.core.presentation.components.SetupButton -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ConfirmationSheet( - state: AppBottomSheetState, - title: String, - summary: String, - confirmText: String = stringResource(R.string.yes), - onConfirm: () -> Unit -) { - val scope = rememberCoroutineScope() - - if (state.isVisible) { - ModalBottomSheet( - sheetState = state.sheetState, - onDismissRequest = { - scope.launch { - state.hide() - } - }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp, vertical = 16.dp) - ) { - Text( - text = title, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(bottom = 16.dp) - .fillMaxWidth() - ) - Text( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(12.dp) - ) - .padding(16.dp), - text = summary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - SetupButton( - onClick = { - scope.launch { - state.hide() - } - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_cancel) - ) - - SetupButton( - onClick = { - onConfirm() - scope.launch { - state.hide() - } - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = confirmText - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt deleted file mode 100644 index ba553776a4..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState - -@Composable -fun DeleteVaultSheet( - state: AppBottomSheetState, - vaultName: String? = null, - onConfirm: () -> Unit -) { - ConfirmationSheet( - state = state, - title = if (vaultName != null) stringResource(R.string.vault_deletion_title_named, vaultName) - else stringResource(R.string.vault_deletion_title), - summary = if (vaultName != null) stringResource(R.string.vault_deletion_summary_named, vaultName) - else stringResource(R.string.vault_deletion_summary), - onConfirm = onConfirm - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt deleted file mode 100644 index 6aac543108..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState - -@Composable -fun NewVaultSheet( - state: AppBottomSheetState, - onConfirm: () -> Unit -) { - ConfirmationSheet( - state = state, - title = stringResource(R.string.create_new_vault_title), - summary = stringResource(R.string.create_new_vault_summary), - onConfirm = onConfirm - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RemovePasswordSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RemovePasswordSheet.kt deleted file mode 100644 index fc32474d9d..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RemovePasswordSheet.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState - -@Composable -fun RemovePasswordSheet( - state: AppBottomSheetState, - onConfirm: () -> Unit -) { - ConfirmationSheet( - state = state, - title = stringResource(R.string.vault_remove_custom_password), - summary = stringResource(R.string.vault_remove_custom_password_summary), - onConfirm = onConfirm - ) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RestoreVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RestoreVaultSheet.kt deleted file mode 100644 index 176b45a3b1..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/RestoreVaultSheet.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState - -@Composable -fun RestoreVaultSheet( - state: AppBottomSheetState, - onConfirm: () -> Unit -) { - ConfirmationSheet( - state = state, - title = stringResource(R.string.restore_vault_title), - summary = stringResource(R.string.restore_vault_summary), - onConfirm = onConfirm - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt deleted file mode 100644 index 635d00157f..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.model.VaultState -import com.dot.gallery.feature_node.presentation.common.components.OptionItem -import com.dot.gallery.feature_node.presentation.common.components.OptionLayout -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SelectVaultSheet( - state: AppBottomSheetState, - vaultState: VaultState, - excludeVault: Vault? = null, - onCreateVault: (() -> Unit)? = null, - onVaultSelected: (Vault) -> Unit -) { - val vaults = remember(vaultState, excludeVault) { - if (excludeVault != null) vaultState.vaults.filter { it.uuid != excludeVault.uuid } - else vaultState.vaults - } - val scope = rememberCoroutineScope() - val vaultOptions = remember(vaults, state) { - vaults.map { vault -> - OptionItem( - text = vault.name, - onClick = { - onVaultSelected(vault) - scope.launch { - state.hide() - } - } - ) - }.toMutableStateList() - } - - if (state.isVisible) { - ModalBottomSheet( - sheetState = state.sheetState, - onDismissRequest = { - scope.launch { - state.hide() - } - }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp, vertical = 16.dp) - .navigationBarsPadding() - ) { - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurface, - fontStyle = MaterialTheme.typography.titleLarge.fontStyle, - fontSize = MaterialTheme.typography.titleLarge.fontSize, - letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing - ) - ) { - append(stringResource(R.string.select_a_vault)) - } - }, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(bottom = 16.dp) - .fillMaxWidth() - ) - if (vaultOptions.isEmpty()) { - Text( - text = stringResource(R.string.vault_create_first), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - if (onCreateVault != null) { - Spacer(modifier = Modifier.height(16.dp)) - SetupButton( - onClick = { - scope.launch { state.hide() } - onCreateVault() - }, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.vault_create_vault) - ) - } - } else { - OptionLayout( - modifier = Modifier.fillMaxWidth(), - optionList = vaultOptions - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPasswordDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPasswordDialog.kt deleted file mode 100644 index 8af3195a90..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPasswordDialog.kt +++ /dev/null @@ -1,609 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.GridOn -import androidx.compose.material.icons.outlined.Password -import androidx.compose.material.icons.outlined.Pin -import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.core.presentation.components.SetupButton -import com.dot.gallery.feature_node.presentation.common.components.OptionItem -import com.dot.gallery.feature_node.presentation.common.components.OptionLayout -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager -import com.dot.gallery.feature_node.presentation.vault.utils.VaultAuthType -import kotlinx.coroutines.launch - -// ────────────────────────────────────────────── -// Unlock dialogs (type-aware) -// ────────────────────────────────────────────── - -/** - * Top-level unlock dialog that delegates to the correct input based on [authType]. - * Falls back to password mode when type is null (legacy). - */ -@Composable -fun VaultPasswordUnlockDialog( - authType: VaultAuthType? = null, - onDismiss: () -> Unit, - onSubmit: (secret: String) -> Unit, - errorMessage: String? = null -) { - when (authType ?: VaultAuthType.PASSWORD) { - VaultAuthType.PIN -> VaultPinUnlockDialog(onDismiss, onSubmit, errorMessage) - VaultAuthType.PATTERN -> VaultPatternUnlockDialog(onDismiss, onSubmit, errorMessage) - VaultAuthType.PASSWORD -> VaultTextPasswordUnlockDialog(onDismiss, onSubmit, errorMessage) - } -} - -@Composable -private fun VaultTextPasswordUnlockDialog( - onDismiss: () -> Unit, - onSubmit: (String) -> Unit, - errorMessage: String? = null -) { - val feedbackManager = rememberFeedbackManager() - var password by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { focusRequester.requestFocus() } - LaunchedEffect(errorMessage) { - if (errorMessage != null) { - feedbackManager.vibrateStrong() - password = "" - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp) - .statusBarsPadding() - .navigationBarsPadding() - ) { - Text( - text = stringResource(R.string.vault_unlock), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.vault_enter_password), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(32.dp)) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.vault_enter_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - if (password.isNotEmpty()) { - feedbackManager.vibrate() - onSubmit(password) - } - } - ), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = stringResource(R.string.toggle_password_visibility_cd) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) - ErrorText(errorMessage) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) - ) { - SetupButton( - onClick = { - feedbackManager.vibrate() - onDismiss() - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_cancel) - ) - SetupButton( - onClick = { - feedbackManager.vibrate() - onSubmit(password) - }, - enabled = password.isNotEmpty(), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - modifier = Modifier.weight(1f), - text = stringResource(R.string.vault_unlock) - ) - } - } -} - -@Composable -private fun VaultPinUnlockDialog( - onDismiss: () -> Unit, - onSubmit: (String) -> Unit, - errorMessage: String? = null -) { - val feedbackManager = rememberFeedbackManager() - var pin by remember { mutableStateOf("") } - val isError = errorMessage != null - - LaunchedEffect(isError) { - if (isError) { - feedbackManager.vibrateStrong() - pin = "" - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp) - .statusBarsPadding() - .navigationBarsPadding() - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(R.string.vault_unlock), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.vault_enter_pin), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(32.dp)) - VaultPinInput( - pin = pin, - isError = isError && pin.isEmpty(), - onPinChange = { pin = it }, - onPinComplete = onSubmit - ) - ErrorText(errorMessage) - Spacer(modifier = Modifier.weight(1f)) - SetupButton( - onClick = { - feedbackManager.vibrate() - onDismiss() - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.action_cancel) - ) - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun VaultPatternUnlockDialog( - onDismiss: () -> Unit, - onSubmit: (String) -> Unit, - errorMessage: String? = null -) { - val feedbackManager = rememberFeedbackManager() - val isError = errorMessage != null - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp) - .statusBarsPadding() - .navigationBarsPadding() - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(R.string.vault_unlock), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.vault_draw_pattern), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - VaultPatternLock( - isError = isError, - onPatternComplete = onSubmit - ) - ErrorText(errorMessage) - Spacer(modifier = Modifier.weight(1f)) - SetupButton( - onClick = { - feedbackManager.vibrate() - onDismiss() - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.action_cancel) - ) - Spacer(modifier = Modifier.height(16.dp)) - } -} - -// ────────────────────────────────────────────── -// Setup bottom sheet (type picker + two-step confirm) -// ────────────────────────────────────────────── - -/** - * Fullscreen bottom sheet for setting up vault authentication. - * Step 1: Pick type (PIN / Pattern / Password) via OptionLayout. - * Step 2: Enter secret. - * Step 3: Confirm secret. - * Calls [onSecretSet] with the chosen type and secret string. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VaultPasswordSetupSheet( - state: AppBottomSheetState, - onSecretSet: (type: VaultAuthType, secret: String) -> Unit -) { - val scope = rememberCoroutineScope() - val feedbackManager = rememberFeedbackManager() - var selectedType by remember { mutableStateOf(null) } - var step by remember { mutableStateOf(SetupStep.CHOOSE_TYPE) } - var firstEntry by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } - - val passwordTooShort = stringResource(R.string.vault_password_too_short) - val pinTooShort = stringResource(R.string.vault_pin_too_short) - val patternTooShort = stringResource(R.string.vault_pattern_too_short) - val mismatch = stringResource(R.string.vault_password_mismatch) - - val pinLabel = stringResource(R.string.vault_type_pin) - val patternLabel = stringResource(R.string.vault_type_pattern) - val passwordLabel = stringResource(R.string.vault_type_password) - - fun reset() { - step = SetupStep.CHOOSE_TYPE - selectedType = null - firstEntry = "" - errorMessage = null - } - - if (state.isVisible) { - ModalBottomSheet( - sheetState = state.sheetState, - onDismissRequest = { - scope.launch { state.hide() } - reset() - }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - sheetMaxWidth = Dp.Unspecified, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp, vertical = 16.dp) - .navigationBarsPadding() - ) { - if (step == SetupStep.CHOOSE_TYPE) { - Spacer(modifier = Modifier.height(16.dp)) - } - // Title - Text( - text = stringResource( - when (step) { - SetupStep.CHOOSE_TYPE -> R.string.vault_choose_lock_type - SetupStep.ENTER_FIRST -> when (selectedType) { - VaultAuthType.PIN -> R.string.vault_enter_pin - VaultAuthType.PATTERN -> R.string.vault_draw_pattern - else -> R.string.vault_enter_password - } - SetupStep.CONFIRM -> when (selectedType) { - VaultAuthType.PIN -> R.string.vault_confirm_pin - VaultAuthType.PATTERN -> R.string.vault_confirm_pattern - else -> R.string.vault_confirm_password - } - } - ), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth() - ) - - when (step) { - SetupStep.CHOOSE_TYPE -> { - Text( - text = stringResource(R.string.vault_custom_password_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - val options = remember(pinLabel, patternLabel, passwordLabel) { - listOf( - OptionItem( - icon = Icons.Outlined.Pin, - text = pinLabel, - onClick = { - feedbackManager.vibrate() - selectedType = VaultAuthType.PIN - step = SetupStep.ENTER_FIRST - } - ), - OptionItem( - icon = Icons.Outlined.GridOn, - text = patternLabel, - onClick = { - feedbackManager.vibrate() - selectedType = VaultAuthType.PATTERN - step = SetupStep.ENTER_FIRST - } - ), - OptionItem( - icon = Icons.Outlined.Password, - text = passwordLabel, - onClick = { - feedbackManager.vibrate() - selectedType = VaultAuthType.PASSWORD - step = SetupStep.ENTER_FIRST - } - ) - ).toMutableStateList() - } - OptionLayout( - modifier = Modifier.fillMaxWidth(), - optionList = options - ) - } - SetupStep.ENTER_FIRST -> { - AuthInput( - type = selectedType ?: VaultAuthType.PASSWORD, - errorMessage = errorMessage, - modifier = Modifier.weight(1f), - onComplete = { secret -> - val minLength = when (selectedType) { - VaultAuthType.PIN -> 6 - else -> 4 - } - if (secret.length < minLength) { - errorMessage = when (selectedType) { - VaultAuthType.PIN -> pinTooShort - VaultAuthType.PATTERN -> patternTooShort - else -> passwordTooShort - } - } else { - firstEntry = secret - errorMessage = null - step = SetupStep.CONFIRM - } - } - ) - SetupButton( - onClick = { - feedbackManager.vibrate() - reset() - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.back) - ) - } - SetupStep.CONFIRM -> { - AuthInput( - type = selectedType ?: VaultAuthType.PASSWORD, - errorMessage = errorMessage, - modifier = Modifier.weight(1f), - onComplete = { secret -> - if (secret == firstEntry) { - onSecretSet(selectedType ?: VaultAuthType.PASSWORD, secret) - scope.launch { state.hide() } - reset() - } else { - errorMessage = mismatch - } - } - ) - SetupButton( - onClick = { - feedbackManager.vibrate() - step = SetupStep.ENTER_FIRST - firstEntry = "" - errorMessage = null - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.back) - ) - } - } - } - } - } -} - -private enum class SetupStep { CHOOSE_TYPE, ENTER_FIRST, CONFIRM } - -@Composable -private fun AuthInput( - type: VaultAuthType, - errorMessage: String?, - modifier: Modifier = Modifier, - onComplete: (String) -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = modifier.fillMaxWidth() - ) { - when (type) { - VaultAuthType.PIN -> { - var pin by remember { mutableStateOf("") } - LaunchedEffect(errorMessage) { if (errorMessage != null) pin = "" } - VaultPinInput( - pin = pin, - isError = errorMessage != null, - onPinChange = { pin = it }, - onPinComplete = onComplete - ) - ErrorText(errorMessage) - } - VaultAuthType.PATTERN -> { - VaultPatternLock( - isError = errorMessage != null, - onPatternComplete = onComplete - ) - ErrorText(errorMessage) - } - VaultAuthType.PASSWORD -> { - var password by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.vault_enter_password)) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { if (password.isNotEmpty()) onComplete(password) } - ), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = stringResource(R.string.toggle_password_visibility_cd) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) - ErrorText(errorMessage) - Spacer(modifier = Modifier.height(16.dp)) - val feedbackManager = rememberFeedbackManager() - SetupButton( - onClick = { - feedbackManager.vibrate() - if (password.isNotEmpty()) onComplete(password) - }, - enabled = password.isNotEmpty(), - applyHorizontalPadding = false, - applyBottomPadding = false, - applyInsets = false, - text = stringResource(R.string.next) - ) - } - } - } -} - -@Composable -private fun ErrorText(message: String?) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = message.orEmpty(), - color = if (message != null) MaterialTheme.colorScheme.error else Color.Transparent, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 4.dp) - ) -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPatternLock.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPatternLock.kt deleted file mode 100644 index c5fdf3221a..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPatternLock.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager - -/** - * A 3×3 pattern lock grid. - * Reports the pattern as a string of node indices (0-8) when the user lifts their finger. - * Example: "012345678" for a full Z-pattern. - */ -@Composable -fun VaultPatternLock( - modifier: Modifier = Modifier, - isError: Boolean = false, - onPatternComplete: (pattern: String) -> Unit -) { - val feedbackManager = rememberFeedbackManager() - val dotColor = MaterialTheme.colorScheme.onSurfaceVariant - val activeColor = MaterialTheme.colorScheme.primary - val errorColor = MaterialTheme.colorScheme.error - - var selectedNodes by remember { mutableStateOf(listOf()) } - var currentDragPos by remember { mutableStateOf(null) } - var dragFingerX by remember { mutableFloatStateOf(0f) } - var dragFingerY by remember { mutableFloatStateOf(0f) } - - val errorAnim = remember { Animatable(0f) } - LaunchedEffect(isError) { - if (isError) { - feedbackManager.vibrateStrong() - errorAnim.snapTo(1f) - errorAnim.animateTo(0f, tween(800)) - selectedNodes = emptyList() - } - } - - val lineColor = if (errorAnim.value > 0f) errorColor else activeColor - val selectedDotColor = if (errorAnim.value > 0f) errorColor else activeColor - - Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(1f) - ) { - Canvas( - modifier = Modifier - .matchParentSize() - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { offset -> - selectedNodes = emptyList() - currentDragPos = offset - dragFingerX = offset.x - dragFingerY = offset.y - val hitNode = hitTest(offset, size.width.toFloat(), size.height.toFloat()) - if (hitNode != null) { - feedbackManager.vibrate() - selectedNodes = listOf(hitNode) - } - }, - onDrag = { change, _ -> - change.consume() - dragFingerX = change.position.x - dragFingerY = change.position.y - currentDragPos = change.position - val hitNode = hitTest( - change.position, - size.width.toFloat(), - size.height.toFloat() - ) - if (hitNode != null && hitNode !in selectedNodes) { - feedbackManager.vibrate() - selectedNodes = selectedNodes + hitNode - } - }, - onDragEnd = { - currentDragPos = null - if (selectedNodes.size >= 4) { - feedbackManager.vibrateStrong() - onPatternComplete(selectedNodes.joinToString("")) - } - if (selectedNodes.size < 4) { - selectedNodes = emptyList() - } - }, - onDragCancel = { - currentDragPos = null - selectedNodes = emptyList() - } - ) - } - ) { - val w = size.width - val h = size.height - val dotRadius = w / 30f - val activeDotRadius = w / 20f - - // Draw connection lines - for (i in 0 until selectedNodes.size - 1) { - val from = nodeCenter(selectedNodes[i], w, h) - val to = nodeCenter(selectedNodes[i + 1], w, h) - drawLine( - color = lineColor, - start = from, - end = to, - strokeWidth = dotRadius * 0.8f, - cap = StrokeCap.Round - ) - } - - // Draw line from last node to finger - if (selectedNodes.isNotEmpty() && currentDragPos != null) { - val lastNode = nodeCenter(selectedNodes.last(), w, h) - drawLine( - color = lineColor.copy(alpha = 0.5f), - start = lastNode, - end = Offset(dragFingerX, dragFingerY), - strokeWidth = dotRadius * 0.6f, - cap = StrokeCap.Round - ) - } - - // Draw dots - for (i in 0 until 9) { - val center = nodeCenter(i, w, h) - val isSelected = i in selectedNodes - drawCircle( - color = if (isSelected) selectedDotColor else dotColor.copy(alpha = 0.4f), - radius = if (isSelected) activeDotRadius else dotRadius, - center = center - ) - if (isSelected) { - drawCircle( - color = Color.White.copy(alpha = 0.3f), - radius = dotRadius * 0.5f, - center = center - ) - } - } - } - } -} - -private fun nodeCenter(index: Int, width: Float, height: Float): Offset { - val col = index % 3 - val row = index / 3 - val cellW = width / 3f - val cellH = height / 3f - return Offset(cellW * col + cellW / 2f, cellH * row + cellH / 2f) -} - -private fun hitTest(pos: Offset, width: Float, height: Float): Int? { - val hitRadius = width / 6f - for (i in 0 until 9) { - val center = nodeCenter(i, width, height) - val dist = (pos - center).getDistance() - if (dist < hitRadius) return i - } - return null -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPinInput.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPinInput.kt deleted file mode 100644 index 6dc6b75675..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultPinInput.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Backspace -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.sp -import com.dot.gallery.R -import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager - -/** - * A numeric PIN input with dot indicators and a dial pad. - * Dots appear only for entered digits (PIN length is not revealed). - * The user must tap the confirm button to submit. - */ -@Composable -fun VaultPinInput( - pin: String, - maxLength: Int = 16, - isError: Boolean = false, - onPinChange: (String) -> Unit, - onPinComplete: (String) -> Unit -) { - val feedbackManager = rememberFeedbackManager() - val errorColor = MaterialTheme.colorScheme.error - val activeColor = MaterialTheme.colorScheme.primary - val dotColor = if (isError) errorColor else activeColor - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - // Dot indicators — only show dots for entered digits - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(20.dp) - ) { - for (i in 0 until pin.length) { - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(dotColor) - ) - } - } - - Spacer(modifier = Modifier.height(40.dp)) - - // Keypad - val keys = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - listOf("⌫", "0", "✓") - ) - for (row in keys) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - for (key in row) { - when (key) { - "✓" -> { - ConfirmButton( - visible = pin.isNotEmpty(), - onClick = { - feedbackManager.vibrateStrong() - onPinComplete(pin) - } - ) - } - "⌫" -> { - IconButton( - onClick = { - if (pin.isNotEmpty()) { - feedbackManager.vibrate() - onPinChange(pin.dropLast(1)) - } - }, - modifier = Modifier.size(80.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Backspace, - contentDescription = stringResource(R.string.backspace_cd), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - else -> { - FilledTonalIconButton( - onClick = { - if (pin.length < maxLength) { - val newPin = pin + key - onPinChange(newPin) - feedbackManager.vibrate() - } - }, - modifier = Modifier.size(80.dp) - ) { - Text( - text = key, - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } - if (key != row.last()) { - Spacer(modifier = Modifier.width(16.dp)) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - } - } -} - -@Composable -private fun ConfirmButton( - visible: Boolean, - onClick: () -> Unit -) { - Box(modifier = Modifier.size(80.dp)) { - AnimatedVisibility( - visible = visible, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() - ) { - FilledTonalIconButton( - onClick = onClick, - modifier = Modifier.size(80.dp) - ) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = stringResource(R.string.action_confirm), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultSecuritySheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultSecuritySheet.kt deleted file mode 100644 index 10e8ee1928..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/VaultSecuritySheet.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.LockOpen -import androidx.compose.material.icons.outlined.Password -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.dot.gallery.R -import com.dot.gallery.core.presentation.components.DragHandle -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.presentation.common.components.OptionItem -import com.dot.gallery.feature_node.presentation.common.components.OptionLayout -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.vault.utils.VaultAuthType -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VaultSecuritySheet( - state: AppBottomSheetState, - vault: Vault?, - currentAuthType: VaultAuthType?, - onSetCustomSecurity: () -> Unit, - onRemoveCustomSecurity: () -> Unit, -) { - val scope = rememberCoroutineScope() - - if (state.isVisible && vault != null) { - ModalBottomSheet( - sheetState = state.sheetState, - onDismissRequest = { - scope.launch { state.hide() } - }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp) - .navigationBarsPadding() - ) { - Text( - text = stringResource(R.string.vault_manage_security), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(bottom = 8.dp) - .fillMaxWidth() - ) - - val removeText = stringResource(R.string.vault_remove_custom_security) - val removeSummary = stringResource(R.string.vault_remove_custom_security_summary) - val setPasswordText = stringResource(R.string.vault_set_password) - val changeText = stringResource(R.string.vault_change_security) - val changeSummary = stringResource(R.string.vault_change_security_summary) - - val options = remember(currentAuthType, removeText, removeSummary, setPasswordText, changeText, changeSummary) { - buildList { - if (currentAuthType != null) { - add( - OptionItem( - icon = Icons.Outlined.LockOpen, - text = removeText, - summary = removeSummary, - onClick = { onRemoveCustomSecurity() } - ) - ) - } - add( - OptionItem( - icon = if (currentAuthType != null) Icons.Outlined.Password else Icons.Outlined.Lock, - text = if (currentAuthType != null) setPasswordText else changeText, - summary = changeSummary, - onClick = { onSetCustomSecurity() } - ) - ) - }.toMutableStateList() - } - - OptionLayout( - modifier = Modifier.fillMaxWidth(), - optionList = options - ) - } - } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/VaultPasswordManager.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/VaultPasswordManager.kt deleted file mode 100644 index 3463bdc87b..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/VaultPasswordManager.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.dot.gallery.feature_node.presentation.vault.utils - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import com.dot.gallery.core.dataStore -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.UUID -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.PBEKeySpec - -/** The type of custom authentication set for a vault. */ -enum class VaultAuthType { PIN, PATTERN, PASSWORD } - -/** How the vault selector screen is protected (gate). */ -enum class GateMode { NONE, DEVICE, CUSTOM } - -/** Result of a [VaultPasswordManager.verifyPassword] call. */ -sealed interface VerifyResult { - /** Secret matched. */ - data object Success : VerifyResult - /** Secret did not match. [attemptsLeft] before lockout (0 = just locked out). */ - data class Failed(val attemptsLeft: Int) : VerifyResult - /** Too many consecutive failures. Try again after [cooldownMs] milliseconds. */ - data class LockedOut(val cooldownMs: Long) : VerifyResult -} - -/** - * Manages per-vault custom authentication (PIN / Pattern / Password) using DataStore. - * Credentials are stored as PBKDF2-hashed secrets: `"type:pbkdf2:salt:hash"`. - * Legacy SHA-256 format (`"type:salt:hash"`) is still accepted for verification - * and transparently upgraded on the next successful [verifyPassword] call. - * When no custom auth is set, device biometric/credential auth is used. - */ -object VaultPasswordManager { - - /** Fixed UUID used to store the global gate authentication credentials. */ - val GATE_UUID: UUID = UUID.fromString("00000000-0000-0000-0000-000000000000") - - private val GATE_MODE_KEY = stringPreferencesKey("vault_gate_mode") - - suspend fun getGateMode(context: Context): GateMode { - val raw = context.dataStore.data.first()[GATE_MODE_KEY] - return raw?.let { runCatching { GateMode.valueOf(it) }.getOrNull() } ?: GateMode.NONE - } - - suspend fun setGateMode(context: Context, mode: GateMode) { - context.dataStore.edit { it[GATE_MODE_KEY] = mode.name } - } - - private const val SALT_LENGTH = 16 - private const val PBKDF2_ITERATIONS = 120_000 - private const val PBKDF2_KEY_LENGTH = 256 - private const val HASH_ALGORITHM = "pbkdf2" - - /** Max consecutive wrong attempts before lockout kicks in. */ - private const val MAX_ATTEMPTS = 5 - /** Base cooldown in ms after MAX_ATTEMPTS failures (30 seconds). */ - private const val BASE_COOLDOWN_MS = 30_000L - - private fun attemptsKeyFor(vaultUuid: UUID) = - intPreferencesKey("vault_attempts_${vaultUuid}") - private fun lockoutKeyFor(vaultUuid: UUID) = - longPreferencesKey("vault_lockout_until_${vaultUuid}") - - private fun keyFor(vaultUuid: UUID) = - stringPreferencesKey("vault_password_${vaultUuid}") - - /** Returns true if any custom auth has been set for the given vault. */ - suspend fun hasCustomPassword(context: Context, vaultUuid: UUID): Boolean { - val key = keyFor(vaultUuid) - return context.dataStore.data.map { prefs -> prefs[key] != null }.first() - } - - /** Returns the auth type for the given vault, or null if none is set. */ - suspend fun getAuthType(context: Context, vaultUuid: UUID): VaultAuthType? { - val key = keyFor(vaultUuid) - val stored = context.dataStore.data.map { prefs -> prefs[key] }.first() ?: return null - val typePart = stored.substringBefore(":") - return runCatching { VaultAuthType.valueOf(typePart) }.getOrNull() - } - - /** Sets custom authentication for the vault with the given [type] and [secret]. */ - suspend fun setPassword( - context: Context, - vaultUuid: UUID, - secret: String, - type: VaultAuthType = VaultAuthType.PASSWORD - ) { - val key = keyFor(vaultUuid) - val salt = ByteArray(SALT_LENGTH).also { SecureRandom().nextBytes(it) } - val hash = pbkdf2Hash(secret, salt) - val stored = "${type.name}:$HASH_ALGORITHM:${salt.toHex()}:${hash.toHex()}" - context.dataStore.edit { prefs -> prefs[key] = stored } - } - - /** Removes custom auth, reverting to device security. */ - suspend fun removePassword(context: Context, vaultUuid: UUID) { - val key = keyFor(vaultUuid) - context.dataStore.edit { prefs -> prefs.remove(key) } - } - - /** Returns remaining lockout time in ms, or 0 if not locked out. */ - suspend fun getRemainingLockout(context: Context, vaultUuid: UUID): Long { - val lockoutUntil = context.dataStore.data.map { prefs -> - prefs[lockoutKeyFor(vaultUuid)] ?: 0L - }.first() - return (lockoutUntil - System.currentTimeMillis()).coerceAtLeast(0L) - } - - /** Verifies the entered [secret] against the stored hash. - * Returns [VerifyResult.LockedOut] if too many failures, [VerifyResult.Failed] on mismatch, - * or [VerifyResult.Success] on match. - * Legacy SHA-256 hashes are transparently upgraded to PBKDF2 on success. */ - suspend fun verifyPassword(context: Context, vaultUuid: UUID, secret: String): VerifyResult { - // Check lockout - val remainingLockout = getRemainingLockout(context, vaultUuid) - if (remainingLockout > 0) { - return VerifyResult.LockedOut(remainingLockout) - } - - val key = keyFor(vaultUuid) - val stored = context.dataStore.data.map { prefs -> prefs[key] }.first() - ?: return VerifyResult.Failed(MAX_ATTEMPTS) - val parts = stored.split(":") - // New format: type:pbkdf2:salt:hash (4 parts) - // Legacy format: type:salt:hash (3 parts) or salt:hash (2 parts) - val salt: ByteArray - val expectedHash: ByteArray - val isPbkdf2: Boolean - val authType: VaultAuthType? - when { - parts.size == 4 && parts[1] == HASH_ALGORITHM -> { - // New PBKDF2 format - authType = runCatching { VaultAuthType.valueOf(parts[0]) }.getOrNull() - salt = parts[2].hexToBytes() - expectedHash = parts[3].hexToBytes() - isPbkdf2 = true - } - parts.size == 3 -> { - // Legacy: type:salt:hash (SHA-256) - authType = runCatching { VaultAuthType.valueOf(parts[0]) }.getOrNull() - salt = parts[1].hexToBytes() - expectedHash = parts[2].hexToBytes() - isPbkdf2 = false - } - parts.size == 2 -> { - // Legacy: salt:hash (SHA-256, no type) - authType = null - salt = parts[0].hexToBytes() - expectedHash = parts[1].hexToBytes() - isPbkdf2 = false - } - else -> return VerifyResult.Failed(MAX_ATTEMPTS) - } - - val actualHash = if (isPbkdf2) pbkdf2Hash(secret, salt) else legacySha256Hash(secret, salt) - val matches = MessageDigest.isEqual(expectedHash, actualHash) - - if (matches) { - // Transparently upgrade legacy SHA-256 hashes to PBKDF2 - if (!isPbkdf2) { - setPassword(context, vaultUuid, secret, authType ?: VaultAuthType.PASSWORD) - } - resetAttempts(context, vaultUuid) - return VerifyResult.Success - } - - // Record failed attempt and possibly trigger lockout - return recordFailedAttempt(context, vaultUuid) - } - - private suspend fun recordFailedAttempt(context: Context, vaultUuid: UUID): VerifyResult { - val attemptsKey = attemptsKeyFor(vaultUuid) - val lockoutKey = lockoutKeyFor(vaultUuid) - var currentAttempts = 0 - context.dataStore.edit { prefs -> - currentAttempts = (prefs[attemptsKey] ?: 0) + 1 - prefs[attemptsKey] = currentAttempts - if (currentAttempts >= MAX_ATTEMPTS) { - // Exponential backoff: 30s, 60s, 120s, … - val multiplier = 1L shl ((currentAttempts / MAX_ATTEMPTS) - 1).coerceAtMost(6) - val cooldown = BASE_COOLDOWN_MS * multiplier - prefs[lockoutKey] = System.currentTimeMillis() + cooldown - } - } - return if (currentAttempts >= MAX_ATTEMPTS) { - val multiplier = 1L shl ((currentAttempts / MAX_ATTEMPTS) - 1).coerceAtMost(6) - VerifyResult.LockedOut(BASE_COOLDOWN_MS * multiplier) - } else { - VerifyResult.Failed(attemptsLeft = MAX_ATTEMPTS - currentAttempts) - } - } - - private suspend fun resetAttempts(context: Context, vaultUuid: UUID) { - context.dataStore.edit { prefs -> - prefs.remove(attemptsKeyFor(vaultUuid)) - prefs.remove(lockoutKeyFor(vaultUuid)) - } - } - - private fun pbkdf2Hash(password: String, salt: ByteArray): ByteArray { - val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH) - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - return factory.generateSecret(spec).encoded - } - - private fun legacySha256Hash(password: String, salt: ByteArray): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(salt) - return digest.digest(password.toByteArray(Charsets.UTF_8)) - } - - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } - - private fun String.hexToBytes(): ByteArray = - chunked(2).map { it.toInt(16).toByte() }.toByteArray() -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/GridMediaWidget.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/GridMediaWidget.kt index 0db565986a..46b7913979 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/GridMediaWidget.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/GridMediaWidget.kt @@ -12,38 +12,74 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color +import android.net.Uri import android.view.View import android.widget.RemoteViews +import androidx.annotation.WorkerThread +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale import com.dot.gallery.R +import com.dot.gallery.core.util.ext.goAsync +import com.dot.gallery.feature_node.data.repository.WidgetRepository import com.dot.gallery.feature_node.presentation.main.MainActivity import com.dot.gallery.feature_node.presentation.widget.data.WidgetBitmapLoader -import com.dot.gallery.feature_node.presentation.widget.data.WidgetPreferences +import com.dot.gallery.injection.qualifier.IoDispatcher +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +@AndroidEntryPoint class GridMediaWidgetReceiver : AppWidgetProvider() { + @Inject + internal lateinit var widgetRepository: WidgetRepository + + @Inject + @IoDispatcher + lateinit var ioDispatcher: CoroutineDispatcher + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - for (appWidgetId in appWidgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) + goAsync(tag = TAG, dispatcher = ioDispatcher) { + appWidgetIds.forEach { widgetId -> + updateWidget( + context = context, + appWidgetManager = appWidgetManager, + appWidgetId = widgetId, + uris = widgetRepository.getMediaUris(widgetId = widgetId), + ) + } } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) - appWidgetIds.forEach { id -> - WidgetPreferences.deleteWidgetData(context, id) - WidgetBitmapLoader.clearCache(context, id) + goAsync(tag = TAG, dispatcher = ioDispatcher) { + appWidgetIds.forEach { widgetId -> + widgetRepository.deleteWidgetData(widgetId = widgetId) + WidgetBitmapLoader.clearCache(context, widgetId) + } } } companion object { + private const val TAG = "GridMediaWidgetReceiver" private const val GRID_SPACING = 2 - fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - val uris = WidgetPreferences.getMediaUris(context, appWidgetId) + /** + * Reads the cached bitmaps from disk and composites the grid, so it must not run on the + * main thread. + */ + @WorkerThread + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + uris: List, + ) { val bitmaps = uris.indices.mapNotNull { index -> WidgetBitmapLoader.loadCachedBitmap(context, appWidgetId, index) } @@ -81,7 +117,7 @@ class GridMediaWidgetReceiver : AppWidgetProvider() { val totalWidth = cols * cellSize + (cols - 1) * GRID_SPACING val totalHeight = rows * cellSize + (rows - 1) * GRID_SPACING - val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888) + val result = createBitmap(totalWidth, totalHeight) val canvas = Canvas(result) canvas.drawColor(Color.DKGRAY) @@ -91,7 +127,7 @@ class GridMediaWidgetReceiver : AppWidgetProvider() { val x = col * (cellSize + GRID_SPACING) val y = row * (cellSize + GRID_SPACING) - val scaled = Bitmap.createScaledBitmap(bitmap, cellSize, cellSize, true) + val scaled = bitmap.scale(cellSize, cellSize, true) canvas.drawBitmap(scaled, x.toFloat(), y.toFloat(), null) if (scaled !== bitmap) scaled.recycle() } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/SingleMediaWidget.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/SingleMediaWidget.kt index b14aea041a..2f596f6da1 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/SingleMediaWidget.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/SingleMediaWidget.kt @@ -11,32 +11,56 @@ import android.content.Context import android.content.Intent import android.view.View import android.widget.RemoteViews +import androidx.annotation.WorkerThread import com.dot.gallery.R +import com.dot.gallery.core.util.ext.goAsync +import com.dot.gallery.feature_node.data.repository.WidgetRepository import com.dot.gallery.feature_node.presentation.main.MainActivity import com.dot.gallery.feature_node.presentation.widget.data.WidgetBitmapLoader -import com.dot.gallery.feature_node.presentation.widget.data.WidgetPreferences +import com.dot.gallery.injection.qualifier.IoDispatcher +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +@AndroidEntryPoint class SingleMediaWidgetReceiver : AppWidgetProvider() { + @Inject + internal lateinit var widgetRepository: WidgetRepository + + @Inject + @IoDispatcher + lateinit var ioDispatcher: CoroutineDispatcher + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - for (appWidgetId in appWidgetIds) { - updateWidget(context, appWidgetManager, appWidgetId) + goAsync(tag = TAG, dispatcher = ioDispatcher) { + appWidgetIds.forEach { widgetId -> + updateWidget(context, appWidgetManager, widgetId) + } } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) - appWidgetIds.forEach { id -> - WidgetPreferences.deleteWidgetData(context, id) - WidgetBitmapLoader.clearCache(context, id) + goAsync(tag = TAG, dispatcher = ioDispatcher) { + appWidgetIds.forEach { widgetId -> + widgetRepository.deleteWidgetData(widgetId = widgetId) + WidgetBitmapLoader.clearCache(context, widgetId) + } } } companion object { + private const val TAG = "SingleMediaWidgetReceiver" + + /** + * Reads the cached bitmap from disk, so it must not run on the main thread. + */ + @WorkerThread fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { val bitmap = WidgetBitmapLoader.loadCachedBitmap(context, appWidgetId, 0) val views = RemoteViews(context.packageName, R.layout.widget_single_content) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/WidgetConfigActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/WidgetConfigActivity.kt index 9423c8a343..dbe61980d6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/WidgetConfigActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/WidgetConfigActivity.kt @@ -9,12 +9,14 @@ import android.appwidget.AppWidgetManager import android.content.Intent import android.net.Uri import android.os.Bundle +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.fragment.app.FragmentActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.core.view.WindowCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.MediaDistributor @@ -22,23 +24,22 @@ import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaSelector import com.dot.gallery.core.MediaSelectorImpl import com.dot.gallery.core.util.SetupMediaProviders +import com.dot.gallery.feature_node.data.model.WidgetType +import com.dot.gallery.feature_node.data.repository.WidgetRepository import com.dot.gallery.feature_node.domain.model.UIEvent import com.dot.gallery.feature_node.domain.util.EventHandler import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import com.dot.gallery.feature_node.presentation.picker.components.PickerScreen import com.dot.gallery.feature_node.presentation.widget.data.WidgetBitmapLoader -import com.dot.gallery.feature_node.presentation.widget.data.WidgetPreferences -import com.dot.gallery.feature_node.presentation.widget.data.WidgetType import com.dot.gallery.ui.theme.GalleryTheme import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState -import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @AndroidEntryPoint class WidgetConfigActivity : FragmentActivity() { @@ -52,6 +53,9 @@ class WidgetConfigActivity : FragmentActivity() { @Inject lateinit var mediaHandler: MediaHandler + @Inject + internal lateinit var widgetRepository: WidgetRepository + val mediaSelector: MediaSelector = MediaSelectorImpl() private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID @@ -83,7 +87,7 @@ class WidgetConfigActivity : FragmentActivity() { } // Detect reconfigure: widget already has saved data - isReconfigure = WidgetPreferences.getWidgetData(this, appWidgetId) != null + isReconfigure = widgetRepository.getWidgetData(widgetId = appWidgetId) != null // For initial config, set CANCELED so backing out doesn't add the widget. // For reconfigure, the widget already exists — just finish normally on back. @@ -162,52 +166,57 @@ class WidgetConfigActivity : FragmentActivity() { return } - // Persist read permission for the selected URIs - selectedMedia.forEach { uri -> - try { - contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } catch (_: SecurityException) { - // Some URIs may not support persistable permissions + val applicationContext = applicationContext + val widgetId = appWidgetId + val selectedWidgetType = widgetType + val distinctMedia = selectedMedia.distinct() + lifecycleScope.launch(Dispatchers.IO) { + val saved = widgetRepository.replaceWidgetData( + widgetId = widgetId, + type = selectedWidgetType, + uris = distinctMedia, + ) + if (!saved) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@WidgetConfigActivity, + R.string.widget_save_failed, + Toast.LENGTH_LONG, + ).show() + } + return@launch } - } - // Clear old cache on reconfigure - if (isReconfigure) { - WidgetBitmapLoader.clearCache(this, appWidgetId) - } - - WidgetPreferences.saveWidgetData( - context = this, - widgetId = appWidgetId, - type = widgetType, - uris = selectedMedia - ) + if (isReconfigure) { + WidgetBitmapLoader.clearCache(applicationContext, widgetId) + } - // Load bitmaps, cache to files, and push widget update - val appContext = applicationContext - val wId = appWidgetId - val wType = widgetType - val uris = selectedMedia - lifecycleScope.launch { - uris.forEachIndexed { index, uri -> - WidgetBitmapLoader.loadAndCacheBitmap(appContext, uri, wId, index) + distinctMedia.forEachIndexed { index, uri -> + WidgetBitmapLoader.loadAndCacheBitmap(applicationContext, uri, widgetId, index) } - // Push RemoteViews via the provider's updateWidget - val awm = AppWidgetManager.getInstance(appContext) - when (wType) { - WidgetType.SINGLE -> SingleMediaWidgetReceiver.updateWidget(appContext, awm, wId) - WidgetType.GRID -> GridMediaWidgetReceiver.updateWidget(appContext, awm, wId) + + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + when (selectedWidgetType) { + WidgetType.SINGLE -> SingleMediaWidgetReceiver.updateWidget( + context = applicationContext, + appWidgetManager = appWidgetManager, + appWidgetId = widgetId, + ) + WidgetType.GRID -> GridMediaWidgetReceiver.updateWidget( + context = applicationContext, + appWidgetManager = appWidgetManager, + appWidgetId = widgetId, + uris = distinctMedia, + ) } val resultValue = Intent().apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, wId) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + } + withContext(Dispatchers.Main) { + setResult(Activity.RESULT_OK, resultValue) + finish() } - setResult(Activity.RESULT_OK, resultValue) - finish() } } - } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetBitmapLoader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetBitmapLoader.kt index cf55b071ce..ccbef98326 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetBitmapLoader.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetBitmapLoader.kt @@ -8,12 +8,13 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.util.Size +import androidx.annotation.WorkerThread import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.dot.gallery.feature_node.presentation.util.toGlideModel +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File object WidgetBitmapLoader { @@ -42,9 +43,10 @@ object WidgetBitmapLoader { } /** - * Reads a previously cached bitmap from file. This is a synchronous call - * safe to use from AppWidgetProvider.onUpdate. + * Reads a previously cached bitmap from file. Synchronous: it hits the disk and decodes a + * full-size JPEG, so callers must already be off the main thread. */ + @WorkerThread fun loadCachedBitmap(context: Context, widgetId: Int, index: Int): Bitmap? { val file = getBitmapFile(context, widgetId, index) if (!file.exists()) return null @@ -72,7 +74,7 @@ object WidgetBitmapLoader { try { val bitmap = Glide.with(appContext) .asBitmap() - .load(uri) + .load(uri.toGlideModel()) .centerCrop() .override(maxWidth, maxHeight) .diskCacheStrategy(DiskCacheStrategy.ALL) @@ -82,14 +84,6 @@ object WidgetBitmapLoader { } catch (_: Exception) { } - // Fallback: ContentResolver.loadThumbnail (API 29+) - try { - val bitmap = appContext.contentResolver.loadThumbnail( - uri, Size(maxWidth, maxHeight), null - ) - return@withContext bitmap - } catch (_: Exception) { } - null } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetPreferences.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetPreferences.kt deleted file mode 100644 index 672b469c75..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/widget/data/WidgetPreferences.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ -package com.dot.gallery.feature_node.presentation.widget.data - -import android.content.Context -import android.net.Uri -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -@Serializable -data class WidgetData( - val widgetId: Int, - val type: WidgetType, - val mediaUris: List -) - -@Serializable -enum class WidgetType { - SINGLE, GRID -} - -object WidgetPreferences { - - private const val PREFS_NAME = "gallery_widget_prefs" - private const val KEY_PREFIX = "widget_" - - private val json = Json { ignoreUnknownKeys = true } - - fun saveWidgetData(context: Context, widgetId: Int, type: WidgetType, uris: List) { - val data = WidgetData( - widgetId = widgetId, - type = type, - mediaUris = uris.map { it.toString() } - ) - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putString("$KEY_PREFIX$widgetId", json.encodeToString(data)) - .commit() - } - - fun getWidgetData(context: Context, widgetId: Int): WidgetData? { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val jsonStr = prefs.getString("$KEY_PREFIX$widgetId", null) ?: return null - return try { - json.decodeFromString(jsonStr) - } catch (_: Exception) { - null - } - } - - fun deleteWidgetData(context: Context, widgetId: Int) { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .remove("$KEY_PREFIX$widgetId") - .apply() - } - - fun getMediaUris(context: Context, widgetId: Int): List { - val data = getWidgetData(context, widgetId) ?: return emptyList() - return data.mediaUris.map { Uri.parse(it) } - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/GlideModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/GlideModule.kt deleted file mode 100644 index 18a5c0c635..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/injection/GlideModule.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.dot.gallery.injection - -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import com.bumptech.glide.Glide -import com.bumptech.glide.Registry -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool -import com.bumptech.glide.load.resource.gif.GifDrawable -import com.bumptech.glide.module.AppGlideModule -import com.dot.gallery.core.decoder.glide.EncryptedFileModelLoader -import com.dot.gallery.core.decoder.glide.EncryptedGenericImageDecoder -import com.dot.gallery.core.decoder.glide.EncryptedGifDecoder -import com.dot.gallery.core.decoder.glide.EncryptedMediaSource -import com.dot.gallery.core.decoder.glide.EncryptedMediaStream -import com.dot.gallery.core.decoder.glide.EncryptedSourceToStreamLoader -import com.dot.gallery.core.decoder.glide.EncryptedStreamingFileLoader -import com.dot.gallery.core.decoder.glide.EncryptedStreamingUriLoader -import com.dot.gallery.core.decoder.glide.EncryptedUriModelLoader -import com.dot.gallery.core.decoder.glide.EncryptedVideoFrameDecoder -import com.dot.gallery.core.decoder.glide.HeifEncryptedDecoder -import com.dot.gallery.core.decoder.glide.HeifEncryptedSourceDecoder -import com.dot.gallery.core.decoder.glide.HeifMimeInputStreamDecoder -import com.dot.gallery.core.decoder.glide.JxlBitmapDecoder -import com.dot.gallery.core.decoder.glide.JxlEncryptedDecoder -import com.dot.gallery.core.decoder.glide.JxlEncryptedSourceDecoder -import com.dot.gallery.core.decoder.glide.MimeInputStream -import com.dot.gallery.core.decoder.glide.MimeInputStreamModelLoader -import com.dot.gallery.core.decoder.glide.StreamingEncryptedVideoFrameDecoder -import java.io.File -import java.io.InputStream - -@GlideModule -class GlideModule: AppGlideModule() { - - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - val pool: BitmapPool = glide.bitmapPool - - // New streaming model loaders (File/Uri -> EncryptedMediaSource -> InputStream) placed first. - registry.prepend( - File::class.java, - EncryptedMediaSource::class.java, - EncryptedStreamingFileLoader.Factory(context) - ) - registry.prepend( - Uri::class.java, - EncryptedMediaSource::class.java, - EncryptedStreamingUriLoader.Factory(context) - ) - registry.prepend( - EncryptedMediaSource::class.java, - java.io.InputStream::class.java, - EncryptedSourceToStreamLoader.Factory() - ) - - // Legacy byte-array path (will still catch cases needing format-specific decoders). - // ModelLoaders: intercept both File and Uri BEFORE defaults. - registry.prepend( - File::class.java, - EncryptedMediaStream::class.java, - EncryptedFileModelLoader.Factory(context) - ) - registry.prepend( - Uri::class.java, - EncryptedMediaStream::class.java, - EncryptedUriModelLoader.Factory(context) - ) - - registry.prepend( - Uri::class.java, - MimeInputStream::class.java, - MimeInputStreamModelLoader.Factory(context) - ) - registry.prepend( - MimeInputStream::class.java, - Bitmap::class.java, - HeifMimeInputStreamDecoder(pool) - ) - registry.prepend( - InputStream::class.java, - Bitmap::class.java, - JxlBitmapDecoder(pool) - ) - - // Decoders for our custom model type - registry.prepend( - EncryptedMediaStream::class.java, - Bitmap::class.java, - HeifEncryptedDecoder(pool) - ) - - // Bridging decoders: EncryptedMediaSource -> Bitmap (HEIF/JXL) without forcing legacy byte array for all images. - registry.prepend( - EncryptedMediaSource::class.java, - Bitmap::class.java, - HeifEncryptedSourceDecoder(pool) - ) - registry.prepend( - EncryptedMediaSource::class.java, - Bitmap::class.java, - JxlEncryptedSourceDecoder(pool) - ) - // Streaming video frame decoder (EncryptedMediaSource -> Bitmap) preferred over legacy byte-array path - registry.prepend( - EncryptedMediaSource::class.java, - Bitmap::class.java, - StreamingEncryptedVideoFrameDecoder(pool, context.applicationContext) { context.cacheDir } - ) - registry.prepend( - EncryptedMediaStream::class.java, - Bitmap::class.java, - JxlEncryptedDecoder(pool) - ) - registry.prepend( - EncryptedMediaStream::class.java, - Bitmap::class.java, - EncryptedVideoFrameDecoder(pool) { context.cacheDir } - ) - // GIF decoder for encrypted media - must be registered before generic image decoder - // to properly handle animated GIFs in vault - registry.prepend( - EncryptedMediaStream::class.java, - GifDrawable::class.java, - EncryptedGifDecoder(context, pool, glide.arrayPool) - ) - registry.prepend( - EncryptedMediaStream::class.java, - Bitmap::class.java, - EncryptedGenericImageDecoder(pool) - ) - } - - // Disable manifest parsing for speed - override fun isManifestParsingEnabled(): Boolean = false - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/AiMediaAnalysisBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/AiMediaAnalysisBindsModule.kt new file mode 100644 index 0000000000..cf21c62f7a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/AiMediaAnalysisBindsModule.kt @@ -0,0 +1,42 @@ +package com.dot.gallery.injection.module.core + +import com.dot.gallery.core.ml.ImageEmbeddingGenerator +import com.dot.gallery.core.ml.ImageEmbeddingGeneratorImpl +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepositoryImpl +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisImpl +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisScheduler +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSchedulerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AiMediaAnalysisBindsModule { + + @Binds + @Singleton + abstract fun bindAiMediaAnalysisRepository( + implementation: AiMediaAnalysisRepositoryImpl, + ): AiMediaAnalysisRepository + + @Binds + @Singleton + abstract fun bindAiMediaAnalysisScheduler( + implementation: AiMediaAnalysisSchedulerImpl, + ): AiMediaAnalysisScheduler + + @Binds + @Singleton + abstract fun bindAiMediaAnalysis(implementation: AiMediaAnalysisImpl): AiMediaAnalysis + + @Binds + @Singleton + abstract fun bindImageEmbeddingGenerator( + implementation: ImageEmbeddingGeneratorImpl, + ): ImageEmbeddingGenerator +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/AppModule.kt similarity index 59% rename from app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt rename to app/src/main/kotlin/com/dot/gallery/injection/module/core/AppModule.kt index 2f17148895..7e1f76228f 100644 --- a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/AppModule.kt @@ -3,38 +3,32 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.injection +package com.dot.gallery.injection.module.core import android.app.Application import android.content.ContentResolver import android.content.Context +import android.content.pm.PackageManager import android.location.Geocoder -import android.os.Build import androidx.room.Room import androidx.work.WorkManager import com.dot.gallery.core.DefaultEventHandler -import com.dot.gallery.core.EditBackupManager -import com.dot.gallery.core.sandbox.IsolatedMetadataParser import com.dot.gallery.core.MediaDistributor import com.dot.gallery.core.MediaDistributorImpl import com.dot.gallery.core.MediaHandler import com.dot.gallery.core.MediaHandlerImpl import com.dot.gallery.core.MediaSelector import com.dot.gallery.core.MediaSelectorImpl +import com.dot.gallery.core.memory.ByteArrayPool +import com.dot.gallery.core.ml.ModelManager +import com.dot.gallery.core.sandbox.IsolatedMetadataParser +import com.dot.gallery.core.workers.MediaCopyScheduler import com.dot.gallery.feature_node.data.data_source.InternalDatabase -import com.dot.gallery.feature_node.data.data_source.KeychainHolder -import com.dot.gallery.feature_node.data.data_source.migration.MIGRATION_12_13 +import com.dot.gallery.feature_node.data.repository.MediaRepository import com.dot.gallery.feature_node.data.repository.MediaRepositoryImpl -import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.domain.util.EventHandler -import com.dot.gallery.core.ml.ModelManager import com.dot.gallery.feature_node.presentation.search.SearchHelper import com.dot.gallery.feature_node.presentation.search.SearchHelperImpl -import com.dot.gallery.core.decryption.DecryptManager -import com.dot.gallery.core.decryption.MediaMetadataSidecarCache -import com.dot.gallery.core.memory.AdaptiveDecryptConfig -import com.dot.gallery.core.metrics.MetricsCollector -import com.dot.gallery.core.memory.ByteArrayPool import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -51,18 +45,21 @@ object AppModule { context.contentResolver @Provides - @Singleton - fun provideDatabase(app: Application): InternalDatabase = - Room.databaseBuilder(app, InternalDatabase::class.java, InternalDatabase.NAME) - .addMigrations(MIGRATION_12_13) - .fallbackToDestructiveMigrationOnDowngrade(true) - .fallbackToDestructiveMigration(false) - .build() + fun providePackageManager(@ApplicationContext context: Context): PackageManager { + return context.packageManager + } @Provides @Singleton - fun provideKeychainHolder(@ApplicationContext context: Context): KeychainHolder = - KeychainHolder(context) + fun provideDatabase(app: Application): InternalDatabase { + // No destructive fallback on upgrade: this database holds state the user authored by hand — + // pinned, ignored and locked albums, groups, collections, categories, timeline settings — + // and wiping it on the first schema bump would be silent data loss. Schemas are exported to + // app/schemas, so every version bump from here on ships a migration. + return Room.databaseBuilder(app, InternalDatabase::class.java, InternalDatabase.NAME) + .fallbackToDestructiveMigrationOnDowngrade(true) + .build() + } @Provides @Singleton @@ -79,8 +76,15 @@ object AppModule { @ApplicationContext context: Context, workManager: WorkManager, repository: MediaRepository, - eventHandler: EventHandler - ): MediaDistributor = MediaDistributorImpl(context, repository, eventHandler, workManager) + eventHandler: EventHandler, + database: InternalDatabase, + ): MediaDistributor = MediaDistributorImpl( + context = context, + repository = repository, + eventHandler = eventHandler, + workManager = workManager, + scannedMediaDao = database.getScannedMediaDao(), + ) @Provides @Singleton @@ -101,14 +105,23 @@ object AppModule { @Provides @Singleton - fun provideMediaRepository( + internal fun provideMediaRepository( @ApplicationContext context: Context, workManager: WorkManager, + mediaCopyScheduler: MediaCopyScheduler, database: InternalDatabase, - keychainHolder: KeychainHolder, geocoder: Geocoder?, isolatedParser: IsolatedMetadataParser, - ): MediaRepository = MediaRepositoryImpl(context, workManager, database, keychainHolder, geocoder, isolatedParser) + ): MediaRepository { + return MediaRepositoryImpl( + context = context, + workManager = workManager, + mediaCopyScheduler = mediaCopyScheduler, + database = database, + geocoder = geocoder, + isolatedParser = isolatedParser, + ) + } @Provides @Singleton @@ -120,34 +133,12 @@ object AppModule { @Provides @Singleton - fun provideGeocoder(@ApplicationContext context: Context): Geocoder? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Geocoder.isPresent()) Geocoder(context) else null - - @Provides - @Singleton - fun provideDecryptManager(@ApplicationContext context: Context, metrics: MetricsCollector): DecryptManager = DecryptManager(context, metrics) - - @Provides - @Singleton - fun provideMediaMetadataSidecarCache(@ApplicationContext context: Context): MediaMetadataSidecarCache = MediaMetadataSidecarCache(context) - - @Provides - @Singleton - fun provideAdaptiveDecryptConfig(app: Application): AdaptiveDecryptConfig = AdaptiveDecryptConfig(app) - - @Provides - @Singleton - fun provideMetricsCollector(): MetricsCollector = MetricsCollector() + fun provideGeocoder(@ApplicationContext context: Context): Geocoder? { + return if (Geocoder.isPresent()) Geocoder(context) else null + } @Provides @Singleton fun provideByteArrayPool(): ByteArrayPool = ByteArrayPool() - @Provides - @Singleton - fun provideEditBackupManager( - @ApplicationContext context: Context, - database: InternalDatabase - ): EditBackupManager = EditBackupManager(context, database.getEditHistoryDao()) - } diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/DispatchersProvidesModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/DispatchersProvidesModule.kt new file mode 100644 index 0000000000..dd6983f1a8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/DispatchersProvidesModule.kt @@ -0,0 +1,30 @@ +package com.dot.gallery.injection.module.core + +import com.dot.gallery.injection.qualifier.DefaultDispatcher +import com.dot.gallery.injection.qualifier.IoDispatcher +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +internal class DispatchersProvidesModule { + + @Provides + @Reusable + @DefaultDispatcher + fun provideDefaultDispatcher(): CoroutineDispatcher { + return Dispatchers.Default + } + + @Provides + @Reusable + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/GlideModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/GlideModule.kt new file mode 100644 index 0000000000..4ad493790f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/GlideModule.kt @@ -0,0 +1,88 @@ +package com.dot.gallery.injection.module.core + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.resource.bitmap.Downsampler +import com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder +import com.bumptech.glide.load.resource.drawable.AnimatedImageDecoder +import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.load.resource.gif.StreamGifDecoder +import com.bumptech.glide.module.AppGlideModule +import com.dot.gallery.core.decoder.glide.GalleryMediaAnimatedImageDecoder +import com.dot.gallery.core.decoder.glide.GalleryMediaBitmapDecoder +import com.dot.gallery.core.decoder.glide.GalleryMediaData +import com.dot.gallery.core.decoder.glide.GalleryMediaGifDecoder +import com.dot.gallery.core.decoder.glide.GalleryMediaModel +import com.dot.gallery.core.decoder.glide.GalleryMediaModelLoader +import com.dot.gallery.core.decoder.glide.SandboxedGalleryImageDecoder +import com.dot.gallery.core.decoder.glide.VerifiedVideoDecoder + +@GlideModule +class GlideModule : AppGlideModule() { + + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + val arrayPool = glide.arrayPool + val bitmapPool = glide.bitmapPool + val imageHeaderParsers = registry.imageHeaderParsers + val platformImageDecoder = StreamBitmapDecoder( + Downsampler( + imageHeaderParsers, + context.resources.displayMetrics, + bitmapPool, + arrayPool, + ), + arrayPool, + ) + val gifDecoder = StreamGifDecoder( + imageHeaderParsers, + ByteBufferGifDecoder( + context, + imageHeaderParsers, + bitmapPool, + arrayPool, + ), + arrayPool, + ) + val animatedImageDecoder = AnimatedImageDecoder.streamDecoder( + imageHeaderParsers, + arrayPool, + ) + + registry.append( + GalleryMediaModel::class.java, + GalleryMediaData::class.java, + GalleryMediaModelLoader.Factory(context = context), + ) + registry.append( + Registry.BUCKET_ANIMATION, + GalleryMediaData::class.java, + GifDrawable::class.java, + GalleryMediaGifDecoder(delegate = gifDecoder), + ) + registry.append( + Registry.BUCKET_ANIMATION, + GalleryMediaData::class.java, + Drawable::class.java, + GalleryMediaAnimatedImageDecoder(delegate = animatedImageDecoder), + ) + registry.append( + Registry.BUCKET_BITMAP, + GalleryMediaData::class.java, + Bitmap::class.java, + GalleryMediaBitmapDecoder( + platformImageDecoder = platformImageDecoder, + sandboxedImageDecoder = SandboxedGalleryImageDecoder(bitmapPool = bitmapPool), + videoDecoder = VerifiedVideoDecoder(bitmapPool = bitmapPool), + ), + ) + } + + override fun isManifestParsingEnabled(): Boolean { + return false + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/MediaCopyBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/MediaCopyBindsModule.kt new file mode 100644 index 0000000000..e8bceb596b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/MediaCopyBindsModule.kt @@ -0,0 +1,28 @@ +package com.dot.gallery.injection.module.core + +import com.dot.gallery.core.workers.MediaCopyScheduler +import com.dot.gallery.core.workers.MediaCopySchedulerImpl +import com.dot.gallery.feature_node.data.repository.MediaCopyRepository +import com.dot.gallery.feature_node.data.repository.MediaCopyRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class MediaCopyBindsModule { + + @Binds + @Reusable + abstract fun bindMediaCopyScheduler( + implementation: MediaCopySchedulerImpl, + ): MediaCopyScheduler + + @Binds + @Reusable + abstract fun bindMediaCopyRepository( + implementation: MediaCopyRepositoryImpl, + ): MediaCopyRepository +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/MotionPhotoBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/MotionPhotoBindsModule.kt new file mode 100644 index 0000000000..3f3f27ce0e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/MotionPhotoBindsModule.kt @@ -0,0 +1,20 @@ +package com.dot.gallery.injection.module.core + +import com.dot.gallery.feature_node.data.repository.MotionPhotoRepository +import com.dot.gallery.feature_node.data.repository.MotionPhotoRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class MotionPhotoBindsModule { + + @Binds + @Reusable + abstract fun bindMotionPhotoRepository( + implementation: MotionPhotoRepositoryImpl, + ): MotionPhotoRepository +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/core/WidgetBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/core/WidgetBindsModule.kt new file mode 100644 index 0000000000..1bb5e38638 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/core/WidgetBindsModule.kt @@ -0,0 +1,20 @@ +package com.dot.gallery.injection.module.core + +import com.dot.gallery.feature_node.data.repository.WidgetRepository +import com.dot.gallery.feature_node.data.repository.WidgetRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class WidgetBindsModule { + + @Binds + @Singleton + abstract fun bindWidgetRepository( + implementation: WidgetRepositoryImpl, + ): WidgetRepository +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/crop/ExternalCropBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/crop/ExternalCropBindsModule.kt new file mode 100644 index 0000000000..6a4bc32d16 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/crop/ExternalCropBindsModule.kt @@ -0,0 +1,36 @@ +package com.dot.gallery.injection.module.crop + +import com.dot.gallery.feature_node.data.externalcrop.ComponentCallerExternalCropUriPermissionChecker +import com.dot.gallery.feature_node.data.externalcrop.ExternalCropIntentParser +import com.dot.gallery.feature_node.data.externalcrop.ExternalCropIntentParserImpl +import com.dot.gallery.feature_node.data.externalcrop.ExternalCropUriPermissionChecker +import com.dot.gallery.feature_node.data.repository.ExternalCropRepository +import com.dot.gallery.feature_node.data.repository.ExternalCropRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ExternalCropBindsModule { + + @Binds + @Reusable + abstract fun bindExternalCropIntentParser( + impl: ExternalCropIntentParserImpl, + ): ExternalCropIntentParser + + @Binds + @Reusable + abstract fun bindExternalCropRepository( + impl: ExternalCropRepositoryImpl, + ): ExternalCropRepository + + @Binds + @Reusable + abstract fun bindExternalCropUriPermissionChecker( + impl: ComponentCallerExternalCropUriPermissionChecker, + ): ExternalCropUriPermissionChecker +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewBindsModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewBindsModule.kt new file mode 100644 index 0000000000..7223d35d1b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewBindsModule.kt @@ -0,0 +1,36 @@ +package com.dot.gallery.injection.module.securereview + +import com.dot.gallery.feature_node.data.repository.SecureReviewMediaRepository +import com.dot.gallery.feature_node.data.repository.SecureReviewMediaRepositoryImpl +import com.dot.gallery.feature_node.domain.securereview.AuthorizeSecureReviewRequest +import com.dot.gallery.feature_node.domain.securereview.AuthorizeSecureReviewRequestImpl +import com.dot.gallery.feature_node.domain.securereview.IsDeviceLocked +import com.dot.gallery.feature_node.domain.securereview.IsDeviceLockedImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class SecureReviewBindsModule { + + @Binds + @Reusable + abstract fun bindAuthorizeSecureReviewRequest( + implementation: AuthorizeSecureReviewRequestImpl, + ): AuthorizeSecureReviewRequest + + @Binds + @Reusable + abstract fun bindIsDeviceLocked( + implementation: IsDeviceLockedImpl, + ): IsDeviceLocked + + @Binds + @Reusable + abstract fun bindSecureReviewMediaRepository( + implementation: SecureReviewMediaRepositoryImpl, + ): SecureReviewMediaRepository +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewProvidesModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewProvidesModule.kt new file mode 100644 index 0000000000..4d913fde9b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/module/securereview/SecureReviewProvidesModule.kt @@ -0,0 +1,24 @@ +package com.dot.gallery.injection.module.securereview + +import android.app.KeyguardManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object SecureReviewProvidesModule { + + @Provides + @Reusable + fun provideKeyguardManager( + @ApplicationContext + context: Context, + ): KeyguardManager { + return context.getSystemService(KeyguardManager::class.java) + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/injection/qualifier/DispatcherQualifiers.kt b/app/src/main/kotlin/com/dot/gallery/injection/qualifier/DispatcherQualifiers.kt new file mode 100644 index 0000000000..95076b688e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/injection/qualifier/DispatcherQualifiers.kt @@ -0,0 +1,11 @@ +package com.dot.gallery.injection.qualifier + +import javax.inject.Qualifier + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher diff --git a/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt deleted file mode 100644 index 6ae2c92709..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.dot.gallery.ui.core.icons - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathFillType.Companion.NonZero -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap.Companion.Butt -import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.ImageVector.Builder -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.unit.dp -import com.dot.gallery.ui.core.Icons - -public val Icons.Encrypted: ImageVector - get() { - if (_encrypted != null) { - return _encrypted!! - } - _encrypted = Builder(name = "Encrypted", - defaultWidth = 24.0.dp, defaultHeight = 24.0.dp, viewportWidth = 960.0f, - viewportHeight = 960.0f).apply { - path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, - strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, - pathFillType = NonZero) { - moveTo(420.0f, 600.0f) - horizontalLineToRelative(120.0f) - lineToRelative(-23.0f, -129.0f) - quadToRelative(20.0f, -10.0f, 31.5f, -29.0f) - reflectiveQuadToRelative(11.5f, -42.0f) - quadToRelative(0.0f, -33.0f, -23.5f, -56.5f) - reflectiveQuadTo(480.0f, 320.0f) - quadToRelative(-33.0f, 0.0f, -56.5f, 23.5f) - reflectiveQuadTo(400.0f, 400.0f) - quadToRelative(0.0f, 23.0f, 11.5f, 42.0f) - reflectiveQuadToRelative(31.5f, 29.0f) - lineToRelative(-23.0f, 129.0f) - close() - moveTo(480.0f, 880.0f) - quadToRelative(-139.0f, -35.0f, -229.5f, -159.5f) - reflectiveQuadTo(160.0f, 444.0f) - verticalLineToRelative(-244.0f) - lineToRelative(320.0f, -120.0f) - lineToRelative(320.0f, 120.0f) - verticalLineToRelative(244.0f) - quadToRelative(0.0f, 152.0f, -90.5f, 276.5f) - reflectiveQuadTo(480.0f, 880.0f) - close() - moveTo(480.0f, 796.0f) - quadToRelative(104.0f, -33.0f, 172.0f, -132.0f) - reflectiveQuadToRelative(68.0f, -220.0f) - verticalLineToRelative(-189.0f) - lineToRelative(-240.0f, -90.0f) - lineToRelative(-240.0f, 90.0f) - verticalLineToRelative(189.0f) - quadToRelative(0.0f, 121.0f, 68.0f, 220.0f) - reflectiveQuadToRelative(172.0f, 132.0f) - close() - moveTo(480.0f, 480.0f) - close() - } - } - .build() - return _encrypted!! - } - -private var _encrypted: ImageVector? = null diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 12e6648b63..52a07cca18 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,35 +1,15 @@ - - - - - - - - + android:viewportWidth="108" + android:viewportHeight="108"> + - + android:fillColor="#000000" + android:pathData="M20,18V6c0,-1.1 -0.9,-2 -2,-2H6c-1.1,0 -2,0.9 -2,2V18c0,1.1 0.9,2 2,2H18c1.1,0 2,-0.9 2,-2zM9.4,14.53 L11.03,16.71 13.61,13.49c0.2,-0.25 0.58,-0.25 0.78,0l2.96,3.7C17.61,17.52 17.38,18 16.96,18H7C6.59,18 6.35,17.53 6.6,17.2l2,-2.67c0.2,-0.26 0.6,-0.26 0.8,0z" /> - diff --git a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml index 81889d190d..52a07cca18 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -3,20 +3,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - + + + diff --git a/app/src/main/res/drawable/ic_vault.xml b/app/src/main/res/drawable/ic_vault.xml deleted file mode 100644 index 4b3f10fa99..0000000000 --- a/app/src/main/res/drawable/ic_vault.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml new file mode 100644 index 0000000000..ef49c99170 --- /dev/null +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-af-rZA/strings.xml b/app/src/main/res/values-af-rZA/strings.xml index 72557489f0..744264aeb7 100644 --- a/app/src/main/res/values-af-rZA/strings.xml +++ b/app/src/main/res/values-af-rZA/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ar-rSA/strings.xml b/app/src/main/res/values-ar-rSA/strings.xml index fcb3205212..94a72e40c7 100644 --- a/app/src/main/res/values-ar-rSA/strings.xml +++ b/app/src/main/res/values-ar-rSA/strings.xml @@ -1,6 +1,6 @@ - ReFra + المعرض الألبومات اليوم @@ -52,9 +52,9 @@ الخط الزمني موقع الوسائط خريطة الموقع - بواسطة %s + Based on ReFra by %s IacobIonut01 - تبرع + Donate to ReFra غيت هاب زر التبرع زر غيت هاب @@ -110,7 +110,6 @@ تعديل البيانات الوصفية Moving to Trash الحذف نهائيًا - %1$s items Trashing %1$s items.. Deleting %1$s items... اضغط مطوّلا للإزالة @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 8d928125a7..695a5c9c18 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-bg-rBG/strings.xml b/app/src/main/res/values-bg-rBG/strings.xml index 72557489f0..67d90c75fb 100644 --- a/app/src/main/res/values-bg-rBG/strings.xml +++ b/app/src/main/res/values-bg-rBG/strings.xml @@ -1,6 +1,6 @@ - ReFra + Галерия Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 72557489f0..be238a59d3 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ca-rES/strings.xml b/app/src/main/res/values-ca-rES/strings.xml index a91e4979e4..161e6fd910 100644 --- a/app/src/main/res/values-ca-rES/strings.xml +++ b/app/src/main/res/values-ca-rES/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeria Albums Avui @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 6e054bbfbd..aa492c5201 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerie Alba Dnes @@ -52,9 +52,9 @@ Časová osa Umístění médií Mapa polohy - od %s + Based on ReFra by %s IacobIonut01 - Přispět + Donate to ReFra GitHub Tlačítko přispění Tlačítko GitHub @@ -110,7 +110,6 @@ Upravit Metadata Přesouvání do koše Trvalé mazání - %1$s položek Vyhazování %1$s položek.. Mazání %1$s položky... Dlouhým stiskem odeberete @@ -179,18 +178,10 @@ Správa všech souborů "Umožní aplikaci vytvářet, upravovat a mazat soubory a alba, která byla dříve omezena systémem. (Zahrnuje alba mimo složky Pictures a DCIM a mimo složky na SD kartě)" Povolit správu všech souborů - Trezor Nástroje Knihovna Biometrické ověření - Odemkněte svůj trezor - Přidání médií do trezoru - Trezor %1$s (%2$s) existuje Uděleno - Nový trezor - Smazat trezor - Neznámý trezor - Skrýt Vyberte, která alba nechcete vidět na hlavní časové ose, na kartě Alba nebo na obou.\n\nMůžete vybrat jednotlivá alba nebo vytvořit regularní výraz, který automaticky skryje všechna alba, která mu odpovídají. Žádná ignorovaná alba Nastavení ignorovaných alb @@ -250,17 +241,10 @@ Skrýt navigační lištu při posouvání Automaticky skrýt navigační lištu při posouvání Navigace - Nastavit trezor - - Název rezoru - Před nastavením trezoru nastavte bezpečnostní opatření telefonu. - Do trezoru se dostanete pomocí bezpečnostních opatření telefonu (heslo nebo biometrické údaje)\n\nŠifrovací klíč je přístupný uvnitř trezoru a slouží k obnovení všech trezorů. - Do trezoru se dostanete pomocí bezpečnostních opatření telefonu (heslo nebo biometrické údaje)\n\nObsah trezoru je šifrován pomocí šifrovacího algoritmu AES256.\nŠifrovací klíč není přístupný uvnitř trezoru a nebude možné trezor přenést. - Trezor \"%1$s\" již existuje Ignorováno Umístění Aktualizovat název souboru - Podpořit tento projekt + Support ReFra Kliknutím zkopírujete Výhradní zvukový výstup Při přehrávání videa pozastavit média přehrávaná na pozadí. (restartuje aplikaci) @@ -282,7 +266,6 @@ Tohle nechci Nechci to, prosím, odstraňte to.\n(Lze znovu povolit v nastavení) Obnovit - Šifrováno Formát datumu Formáty datumu Nastavit vlastní formát datumu @@ -312,21 +295,7 @@ Filtry Kreslit Velikost - Šifrování médií... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Dešifrování trezoru - Aktuální trezor a jeho obsah budou dešifrovány a obnoveny.\nDíky této akci bude váš obsah v galerii opět přístupný. - Potvrdit dešifrování? Ano - Potvrdit smazání? - Aktuální trezor a jeho obsah budou trvale odstraněny.\nTuto akci nelze vrátit zpět. - Vytvořit nový trezor? - Do trezoru se dostanete pomocí bezpečnostních opatření telefonu (heslo nebo biometrické údaje)\n\nŠifrovací klíč je přístupný uvnitř trezoru a slouží k obnovení všech trezorů. - Vyberte trezor Taken on Resolution Dimensions diff --git a/app/src/main/res/values-da-rDK/strings.xml b/app/src/main/res/values-da-rDK/strings.xml index 448a978499..dd71bc845e 100644 --- a/app/src/main/res/values-da-rDK/strings.xml +++ b/app/src/main/res/values-da-rDK/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleri Albummer I dag @@ -52,9 +52,9 @@ Tidslinje Medie lokation Placeringskort - af %s + Based on ReFra by %s IacobIonut01 - Doner + Donate to ReFra Github Donerknap Github-knap @@ -110,7 +110,6 @@ Rediger metadata Flyt til papirkurv Permanent sletning - %1$s elementer Flytter %1$s elementer til papirkurv... Sletter %1$s elementer... Langt tryk for at fjerne @@ -179,18 +178,10 @@ Administrer alle filer "Giver appen mulighed for at oprette, ændre og slette filer og album, som tidligere var begrænset af systemet. (Inkluderer filer uden for mapperne Pictures, DCIM og filer på SD-kortet)" Tillad at administrere alle filer - Boks Værktøjer Bibliotek Biometrisk godkendelse - Oplås boks - Tilføj medie til boks - Boks %1$s (%2$s) findes Godkendt - Ny boks - Slet boks - Ukendt boks - Skjul Vælg, hvilke album du ikke ønsker at se på hovedtidslinjen, under albummer eller begge dele.\n\nDu kan enten vælge individuelle album eller oprette et regulært udtryk for automatisk at skjule alle album, der stemmer overens det. Ingen ignorerede albummer Opsæt ignorerede albummer @@ -250,17 +241,10 @@ Skjul navigationslinjen ved rulning Skjul automatisk navigationslinjen, mens du ruller Navigation - Opsæt boks - - Navn på boks - Opsæt venligst en sikkerhedsforanstaltning på telefonen, før du opsætter boksen. - Du får adgang til boksen ved hjælp af din telefons sikkerhedsforanstaltninger (adgangskode eller biometri)\n\nKrypteringsnøglen er tilgængelig inde i boksen og bruges til at gendanne bokse. - Du får adgang til boksen ved hjælp af din telefons sikkerhedsforanstaltninger (adgangskode eller biometri)\n\nBoksens indhold er krypteret ved hjælp af en AES256-krypteringsalgoritme.\nDer er ikke adgang til krypteringsnøglen inde i boksen, og du vil ikke kunne overføre din boks. - Boks \"%1$s\" findes allerede Ignoreret Placering Opdater filnavn - Støt projektet + Support ReFra Tryk for at kopiere Tag lydfokus Hvis du aktiverer dette, får du lydfokus, når du afspiller medier. Alle medier, der afspilles i baggrunden, vil blive sat på pause, mens videoen afspilles. (Genstart af app påkrævet) @@ -282,7 +266,6 @@ Jeg ønsker ikke dette Jeg ønsker ikke dette, fjern det.\n(Det kan aktiveres igen i indstillingerne) Gendan - Krypteret Datoformat Datoformater Indstil brugerdefinerede datoformater @@ -312,21 +295,7 @@ Filtre Opmærkning Størrelse - Krypterer medier... - Kryptering af medier - Dekryptering af medier - %1$d%% gennemført - Boks handlinger - Status på kryptering / dekryptering - Dekrypter boks - Den aktuelle boks og dens indhold bliver dekrypteret og gendannet.\nDenne handling vil gøre dit indhold tilgængeligt igen i galleriet. - Bekræft dekryptering? Ja - Bekræft sletning? - Den aktuelle boks og dens indhold slettes permanent.\nDenne handling kan ikke fortrydes. - Opret ny boks? - Du får adgang til boksen ved hjælp af din telefons sikkerhedsforanstaltninger (adgangskode eller biometri)\n\nKrypteringsnøglen er tilgængelig inde i boksen og bruges til at gendanne bokse. - Vælg boks Taget den Opløsning Dimensioner diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 23c01183f2..dc8e47582b 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerie Alben Heute @@ -52,9 +52,9 @@ Zeitleiste Medienstandort Standortkarte - von %s + Based on ReFra by %s IacobIonut01 - Spenden + Donate to ReFra GitHub \"Spenden\"-Knopf \"GitHub\"-Knopf @@ -110,7 +110,6 @@ Metadaten bearbeiten In den Papierkorb verschieben Endgültig löschen - %1$s Dateien Verschiebe %1$s Dateien in den Papierkorb... Lösche %1$s Dateien... Lang drücken zum Entfernen @@ -179,18 +178,10 @@ Alle Dateien verwalten "Erlaubt der App Dateien und Alben zu erstellen, zu bearbeiten und zu löschen, welche zuvor vom System eingeschränkt waren. (Beinhaltet Alben außerhalb der Bilder und DCIM Ordner und die auf der SD-Karte)" Verwalten aller Dateien zulassen - Tresor Werkzeuge Bibliothek Biometrische Authentifizierung - Entsperre deinen Tresor - Medien zum Tresor hinzufügen - Tresor %1$s (%2$s) existiert Gewährt - Neuer Tresor - Tresor löschen - Unbekannter Tresor - Verbergen Auswählen, welche Alben in der Haupt-Zeitleiste, in der Registerkarte Alben oder in beiden nicht sichtbar sind.\n\nDu kannst entweder einzelne Alben auswählen oder ein Suchmuster erstellen, um automatisch alle Alben auszublenden, die diesem Muster entsprechen. Keine ignorierten Alben Ignorierte Alben einrichten @@ -250,17 +241,10 @@ Navigationsleiste beim Scrollen ausblenden Beim Scrollen automatisch die Navigationsleiste ausblenden Navigation - Tresor einrichten - - Bezeichnung des Tresors - Bitte lege vor der Einrichtung des Tresors eine Telefonsicherheitseinstellung fest. - Der Zugriff auf den Tresor erfolgt über die Sicherheitsvorkehrungen deines Telefons (Passwort oder biometrische Daten)\n\nDer Verschlüsselungsschlüssel kann im Tresor eingesehen werden und wird zur Wiederherstellung des Tresors verwendet. - Der Zugriff auf den Tresor erfolgt über die Sicherheitsmaßnahmen deines Telefons (Passwort oder biometrische Daten)\n\nDer Inhalt des Tresors wird mit einem AES256-Verschlüsselungsalgorithmus verschlüsselt.\nAuf den Verschlüsselungsschlüssel kann innerhalb des Tresors nicht zugegriffen werden und du kannst deinen Tresor nicht übertragen. - Tresor \"%1$s\" existiert bereits Ignoriert Standort Dateiname aktualisieren - Das Projekt unterstützen + Support ReFra Zum Kopieren anklicken Audio-Fokus übernehmen Ist diese Option aktiviert, wird bei der Wiedergabe von Medien der Audio-Fokus übernommen. Alle Medien, die im Hintergrund abgespielt werden, werden angehalten, während das Video abgespielt wird. (Neustart der App erforderlich) @@ -282,7 +266,6 @@ Ich möchte das nicht Ich möchte das nicht, bitte entfernen.\n(Es kann in den Einstellungen wieder aktiviert werden) Wiederherstellen - Verschlüsselt Datumsformat Datumsformate Lege deine eigenen Datumsformate fest @@ -312,21 +295,7 @@ Filter Markieren Größe - Medien verschlüsseln... - Verschlüssele Medien - Entschlüssele Medien - %1$d%% abgeschlossen - Tresoroperationen - Fortschritt der Verschlüsselungs-/Entschlüsselungsaufgaben - Tresor entschlüsseln - Der aktuelle Tresor und sein Inhalt werden entschlüsselt und wiederhergestellt.\nDurch diese Aktion werden deine Inhalte in der Galerie wieder zugänglich. - Entschlüsselung bestätigen? Ja - Löschen bestätigen? - Der aktuelle Tresor und sein Inhalt werden dauerhaft gelöscht.\nDiese Aktion kann nicht rückgängig gemacht werden. - Neuen Tresor erstellen? - Der Zugriff auf den Tresor erfolgt über die Sicherheitsvorkehrungen deines Telefons (Passwort oder biometrische Daten)\n\nDer Verschlüsselungsschlüssel kann im Tresor eingesehen werden und wird zur Wiederherstellung des Tresors verwendet. - Tresor wählen Aufgenommen am Auflösung Abmessungen diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml index fb25baddcf..d945047aa1 100644 --- a/app/src/main/res/values-el-rGR/strings.xml +++ b/app/src/main/res/values-el-rGR/strings.xml @@ -1,6 +1,6 @@ - ReFra + Συλλογή Άλμπουμ Σήμερα @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index a9ab8c3ae0..366a65b24a 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index ee05e36c10..d5b4cb0373 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galería Álbumes Hoy @@ -52,9 +52,9 @@ Cronología Ubicación de archivos Mapa de ubicaciones - por %s + Based on ReFra by %s IacobIonut01 - Donar + Donate to ReFra Github Botón para donar Botón hacia GitHub @@ -110,7 +110,6 @@ Editar metadatos Mover a la papelera Borrar permanentemente - %1$s elementos A papelera %1$s elementos.. Borrando %1$s elementos... Pulsación larga para quitar @@ -179,18 +178,10 @@ Controlar todos los archivos "Permite crear, modificar y eliminar ficheros y álbumes que fueron previamente restringidos por el sistema. (Incluyendo álbumes fuera de las carpetas Pictures y DCIM y las de la tarjeta SD" Permitir administrar todos los archivos - Bóveda Herramientas Biblioteca Autenticación biométrica - Desbloquear tu Bóveda - Añadir archivo a la Bóveda - La Bóveda %1$s (%2$s) ya existe Concedido - Nueva Bóveda - Eliminar Bóveda - Bóveda Desconocida - Ocultar Selecciona los álbumes que te gustaría ocultar de la pestaña de Línea de Tiempo, Álbumes o ambas.\n\nPuedes seleccionar tanto álbumes individuales como crear una expresión regular para ocultar automáticamente cualquier álbum que coincida con ella. Ningún álbum ignorado Configurar álbumes ignorados @@ -250,17 +241,10 @@ Ocultar la barra de navegación al desplazarse Ocultar automáticamente la barra de navegación al desplazarse Navegación - Configurar tu Bóveda - - Nombre de la Bóveda - Por favor, configure una medida de seguridad telefónica antes de configurar la cámara acorazada. - Se accederá a la cámara acorazada utilizando las medidas de seguridad de tu teléfono (contraseña o biometría)\n\nSe puede acceder a la clave de cifrado dentro de la cámara acorazada y se utilizará para restaurar cualquier cámara acorazada. - Podrá acceder al almacén utilizando las medidas de seguridad de su teléfono (contraseña o datos biométricos)\n\nEl contenido del almacén se cifra mediante un algoritmo de cifrado AES256.\nNo se puede acceder a la clave de cifrado dentro de la cámara ni transferirla. - La Bóveda %1$s ya existe Ignorado Ubicación Actualizar nombre del archivo - Apoya el proyecto + Support ReFra Haz clic para copiar Tomar enfoque de audio Al activar esta opción, el audio se centrará en la reproducción multimedia. Cualquier medio que se reproduzca en segundo plano se detendrá mientras se reproduce el vídeo. (Es necesario reiniciar la aplicación) @@ -282,7 +266,6 @@ No quiero esto No quiero esto, por favor quítalo.\n(Puede ser reactivado en los ajustes) Restaurar - Encriptado Formato de fecha Formatos de Fecha Establece tus propios formatos de fecha personalizados @@ -312,21 +295,7 @@ Filtros Margen Tamaño - Cifrando medios... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Descifrar bóveda - La bóveda actual y su contenido se descifrarán y restaurarán.\nEsta acción hará que tu contenido vuelva a ser accesible en la Galería. - ¿Confirmar descifrado? - ¿Confirmar eliminación? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - ¿Crear nueva bóveda? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolución Dimensiones diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 24a5a55139..347289b08d 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleria Albumit Tänään @@ -52,9 +52,9 @@ Aikajana Median sijainti Sijaintikartta - Kehittänyt: %s + Based on ReFra by %s IacobIonut01 - Lahjoita + Donate to ReFra Github Lahjoita-painike Github-painike @@ -84,7 +84,7 @@ Kuva Video Sulje - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media Tämä asetus ainoastaan ohittaa ylimääräiset vahvistusikkunat, kun suoritetaan erilaisia toimintoja, kuten suosikiksi merkitseminen. Ohita Salli @@ -110,7 +110,6 @@ Muokkaa Metatietoja Siirretään roskakoriin Poistetaan Pysyvästi - %1$s tiedostoa Siirretään %1$s tiedostoa roskakoriin.. Poistetaan %1$s tiedostoa... Paina pitkään poistaaksesi @@ -179,18 +178,10 @@ Hallitse Kaikkia Tiedostoja "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Salli Kaikkien Tiedostojen Hallinta - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7c8b65617d..19841845c7 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerie Albums Aujourd\'hui @@ -52,9 +52,9 @@ Chronologie Emplacement des médias Carte de localisation - par %s + Based on ReFra by %s IacobIonut01 - Faire un don + Donate to ReFra Github Bouton de donation Bouton Github @@ -110,7 +110,6 @@ Modifier les métadonnées Déplacement vers la corbeille Suppression définitive - %1$s éléments Suppression de %1$s éléments.. Suppression définitive de %1$s éléments... Appui long pour retirer @@ -179,18 +178,10 @@ Gérer tous les fichiers "Permet à l'application de créer, modifier et supprimer des fichiers et des albums qui étaient auparavant limités par le système. (Inclut les albums en dehors des dossiers Pictures et DCIM et ceux de la carte SD)" Autoriser la gestion de tous les fichiers - Coffre-fort Outils Bibliothèque Authentification biométrique - Déverrouiller votre coffre-fort - Ajouter un média au coffre-fort - Le coffre-fort %1$s (%2$s) existe Autorisé - Nouveau coffre-fort - Supprimer le coffre-fort - Coffre-fort inconnu - Masquer Sélectionnez les albums que vous ne souhaitez pas voir dans la Timeline principale, dans l\'onglet Albums ou dans les deux.\n\nVous pouvez sélectionner des albums individuels ou créer un motif de caractère générique pour masquer automatiquement tous les albums qui y correspondent. Aucun album ignoré Configurer les albums ignorés @@ -250,17 +241,10 @@ Masquer la barre de navigation lors du défilement Masquer automatiquement la barre de navigation lors du défilement Navigation - Configurer votre coffre-fort - - Nom du coffre-fort - Veuillez mettre en place une mesure de sécurité téléphonique avant de configurer le coffre-fort. - L\'accès au coffre-fort se fera en utilisant les mesures de sécurité de votre téléphone (mot de passe ou données biométriques)\n\nLa clé de chiffrement est accessible à l\'intérieur du coffre-fort et sera utilisée pour restaurer tout coffre-fort. - L\'accès au coffre-fort se fait par les mesures de sécurité de votre téléphone (mot de passe ou données biométriques)\n\nLe contenu du coffre-fort est chiffré à l\'aide d\'un algorithme de chiffrement AES256.\nLa clé de chiffrement n\'est pas accessible à l\'intérieur du coffre-fort et vous ne pourrez pas transférer votre coffre-fort. - Le coffre-fort \"%1$s\" existe déjà Ignoré Emplacement Mettre à jour le nom du fichier - Soutenir le projet + Support ReFra Cliquer pour copier Prendre la priorité sur l\'audio L\'activation de cette option permet de prendre la priorité de l\'audio lors de la lecture d\'un média. Tout média en cours de lecture en arrière-plan sera mis en pause pendant la lecture de la vidéo. (Redémarrage de l\'application nécessaire) @@ -282,7 +266,6 @@ Je ne veux pas voir ça Je ne veux pas de cette fonction, veuillez la supprimer.\n(Elle peut être réactivée dans les paramètres) Restaurer - Chiffré Format de date Formats de date Définissez vos propres formats de date @@ -312,21 +295,7 @@ Filtres Marquage Taille - Chiffrement des médias… - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Déchiffrer le coffre - L\'espace de stockage actuel et son contenu seront déchiffrés et restaurés.\nCette action rendra votre contenu à nouveau accessible dans la Galerie. - Confirmer le déchiffrement ? Oui - Confirmer la suppression ? - Le coffre actuel et son contenu seront définitivement supprimés.\nCette action ne peut pas être annulée. - Créer un nouveau coffre ? - L\'accès au coffre se fera par le biais des mesures de sécurité de votre téléphone (mot de passe ou données biométriques).\n\nLa clé de chiffrement est accessible à l\'intérieur du coffre et sera utilisée pour restaurer les coffres. - Sélectionner un coffre Prise le Résolution Dimensions diff --git a/app/src/main/res/values-ga-rIE/strings.xml b/app/src/main/res/values-ga-rIE/strings.xml index 04b730a30a..e1eb11fa45 100644 --- a/app/src/main/res/values-ga-rIE/strings.xml +++ b/app/src/main/res/values-ga-rIE/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-gl-rES/strings.xml b/app/src/main/res/values-gl-rES/strings.xml index 9b7b8dda6e..7687403620 100644 --- a/app/src/main/res/values-gl-rES/strings.xml +++ b/app/src/main/res/values-gl-rES/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Álbums Hoxe @@ -52,9 +52,9 @@ Cronoloxía Localización de arquivos Mapa de localizacións - por %s + Based on ReFra by %s IacobIonut01 - Doar + Donate to ReFra Github Botón de doar Botón de Github @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index 8f184fb5dd..28e13897b5 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -1,6 +1,6 @@ - ReFra + गैलरी एल्बम आज @@ -52,9 +52,9 @@ समयरेखा Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-hr-rHR/strings.xml b/app/src/main/res/values-hr-rHR/strings.xml index 85e0c0cedc..65bb8e52f3 100644 --- a/app/src/main/res/values-hr-rHR/strings.xml +++ b/app/src/main/res/values-hr-rHR/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerija Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 72557489f0..21d0e585fb 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galéria Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml index 8e7e1991e7..a0e84692d0 100644 --- a/app/src/main/res/values-in-rID/strings.xml +++ b/app/src/main/res/values-in-rID/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeri Album Hari ini @@ -52,9 +52,9 @@ Waktu Lokasi Media Lokasi Map - Dibuat Oleh %s + Based on ReFra by %s IacobIonut01 - Donasi + Donate to ReFra Github Tombol Donasi Tombol Github @@ -110,7 +110,6 @@ Edit Metadata Pindah ke Sampah Menghapus Secara Permanen - %1$s item Sampah %1$s item.. Menghapus %1$sitem... Tekan lama untuk menghapus @@ -179,18 +178,10 @@ Kelola Semua File "Memungkinkan aplikasi untuk membuat, mengubah, dan menghapus file serta album yang sebelumnya dibatasi oleh sistem. (Termasuk album di luar folder Pictures dan DCIM serta album yang ada di Kartu SD)" Izinkan untuk Mengelola Semua File - Vault Peralatan Perpustakaan Biometric Autentikasi - Buka Kunci Vault Anda - Tambahkan Media ke Vault - Vault %1$s (%2$s) ada Diberikan - Vault Baru - Hapus Vault - Vault Tidak Dikenal - Sembunyikan Pilih album mana yang tidak ingin Anda lihat di Timeline utama, di tab Album, atau keduanya.\n\nAnda dapat memilih album satu per satu atau membuat pola wildcard untuk menyembunyikan album secara otomatis. Tidak ada album yang diabaikan Siapkan album yang diabaikan @@ -250,17 +241,10 @@ Sembunyikan bilah navigasi saat menggulir Sembunyikan bilah navigasi secara otomatis saat menggulir Navigasi - Siapkan Vault Anda - - Nama Vault - Harap siapkan tindakan pengamanan telepon sebelum menyiapkan brankas. - Brankas akan diakses menggunakan tindakan keamanan telepon Anda (kata sandi atau biometrik)\n\nKunci enkripsi dapat diakses di dalam brankas dan akan digunakan untuk memulihkan brankas mana pun. - Brankas akan diakses menggunakan langkah-langkah keamanan telepon Anda (kata sandi atau biometrik)\n\nIsi brankas dienkripsi menggunakan algoritma enkripsi AES256.\nKunci enkripsi tidak dapat diakses di dalam - Vault \"%1$s\" sudah ada Abaikan Lokasi Update nama file - Dukung proyeknya + Support ReFra Klik untuk menyalin Ambil Fokus Audio Mengaktifkan ini akan memfokuskan audio saat memutar media. Semua media yang diputar di latar belakang akan dijeda saat video diputar. (Diperlukan memulai ulang aplikasi) @@ -282,7 +266,6 @@ aku tidak menginginkan ini Saya tidak menginginkannya, silakan hapus.\n(Dapat diaktifkan kembali di pengaturan) Memulihkan - Terenkripsi Format Tanggal Format Tanggal Tetapkan format tanggal kustom Anda sendiri @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index 56338e21f5..d15eb68ab3 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleria Album Oggi @@ -52,9 +52,9 @@ Storico Percorso dei media Posizione - di %s + Based on ReFra by %s IacobIonut01 - Dona + Donate to ReFra GitHub Tasto per le donazioni Tasto Github @@ -110,7 +110,6 @@ Modifica i metadati Sposta nel Cestino Elimina definitivamente - %1$s elementi Cestino %1$s elementi... Cancello %1$s elementi... Premi a lungo per rimuovere @@ -179,18 +178,10 @@ Gestisci tutti i file "Consente all'app di creare, modificare ed eliminare file e album che erano precedentemente gestiti dal sistema. (Compresi gli album fuori dalla cartella Pictures e DCIM, compresi i file sulla scheda SD)" Consenti la gestione di tutti i file - Cassaforte Strumenti Libreria Autenticazione Biometrica - Sblocca la cassaforte - Aggiungi Media alla Cassaforte - Cassaforte %1$s (%2$s) esiste Concesso - Nuova Cassaforte - Elimina Cassaforte - Cassaforte Sconosciuta - Nascondi Seleziona gli album che non desideri vedere nella Timeline principale, nella scheda Album o in entrambe.\n\nÈ possibile selezionare singoli album o creare un modello di caratteri jolly per nascondere automaticamente tutti gli album che corrispondono ad esso. Nessun album ignorato Imposta album ignorati @@ -250,17 +241,10 @@ Nascondi barra di navigazione durante lo scorrimento Nascondi automaticamente la barra di navigazione durante lo scorrimento Navigazione - Imposta la cassaforte - - Nome della cassaforte - Si prega di impostare una misura di sicurezza del telefono prima di configurare la cassaforte. - La cassaforte sarà accessibile utilizzando le misure di sicurezza del telefono (password o biometria)\n\n La chiave di crittografia è accessibile all\'interno della cassaforte e verrà utilizzata per ripristinare qualsiasi cassaforte. - La cassaforte sarà accessibile utilizzando le misure di sicurezza del telefono (password o biometria)\n\nI contenuti della cassaforte sono crittografati con un algoritmo di crittografia AES256.\nLa chiave di crittografia non è accessibile all\'interno della cassaforte e non sarai in grado di trasferire la tua cassaforte. - Cassaforte \"%1$s\" già esistente Ignorato Posizione Aggiorna nome file - Supporta il progetto + Support ReFra Clicca per copiare Prendi il controllo dell\'audio Abilitando questa opzione, l\'audio viene messo in primo piano durante la riproduzione dei media. Qualsiasi media in riproduzione in background verrà messo in pausa durante la riproduzione del video. (È necessario riavviare l\'applicazione) @@ -282,7 +266,6 @@ Non lo voglio Non lo voglio, per favore rimuovetelo.\n(Può essere abilitato nuovamente nelle impostazioni) Ripristina - Crittografato Formato Data Formati Data Imposta i tuoi formati di data personalizzati @@ -312,21 +295,7 @@ Filtri Marcare Dimensione - Codificazione dei media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decodifica l\'archivio protetto - Il presente archivio protetto e il suo contenuto saranno decodificati e ripristinati.\nQuesta azione renderà i tuoi contenuti nuovamente accessibili nella Galleria. - Confermi la decodifica? - Confermi la cancellazione? - Il presente archivio e il suo contenuto saranno eliminati permanentemente.\nQuesta azione non è reversibile. - Vuoi creare un nuovo archivio? - Sarà fatto l\'accesso all\'archivio usando le misure di sicurezza del tuo telefono (password o biometria)\n\nPuoi accedere alla chiave di codifica nell\'archivio e puoi usarla per ripristinare qualunque archivio. - Seleziona un archivio Scattata il Risoluzione Dimensioni diff --git a/app/src/main/res/values-iw-rIL/strings.xml b/app/src/main/res/values-iw-rIL/strings.xml index c440e01edc..b235a0c133 100644 --- a/app/src/main/res/values-iw-rIL/strings.xml +++ b/app/src/main/res/values-iw-rIL/strings.xml @@ -1,6 +1,6 @@ - ReFra + גלריה Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 22a9bcb333..b6eb464db9 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1,6 +1,6 @@ - ReFra + ギャラリー アルバム 今日 @@ -52,9 +52,9 @@ タイムライン メディアの位置情報 地図 - 作成者: %s + Based on ReFra by %s IacobIonut01 - 寄付 + Donate to ReFra GitHub 寄付ボタン GitHub ボタン @@ -84,7 +84,7 @@ 写真 動画 閉じる - ReFraにメディアの管理を許可する + Galleryにメディアの管理を許可する この設定は、お気に入り登録などの様々なアクションを実行する際に、追加の確認ダイアログをスキップします。 スキップ 許可 @@ -110,7 +110,6 @@ メタデータを編集 ゴミ箱に移動 永久に削除 - %1$s アイテム %1$s 個のアイテムをゴミ箱に移動中... %1$s 個のアイテムを削除中... 長押しで削除 @@ -179,18 +178,10 @@ すべてのファイルを管理 "システムによって制限されていたファイルやアルバムの作成、変更、削除を可能にします。(ピクチャとDCIMフォルダの外のアルバムとSDカードからのものを含む)" すべてのファイルの管理を許可する - 保管庫 ツール ライブラリ 生体認証 - 保管庫のロックを解除 - メディアを保管庫に追加する - Vault %1$s (%2$s) exists 許可済み - 新しい保管庫 - 保管庫を削除 - 不明な保管庫 - 隠す メインタイムライン、アルバムタブ、またはその両方で表示したくないアルバムを選択してください。\n\n個別のアルバムを選択するか、wildcardパターンを作成して、それに一致するアルバムを自動的に非表示にすることができます。 非表示にされたアルバムがありません 非表示アルバムの設定 @@ -250,17 +241,10 @@ スクロール時にナビゲーションバーを隠す スクロール中にナビゲーションバーを自動的に隠す ナビゲーション - 保管庫の設定 - - 保管庫の名前 - 保管庫をセットアップする前に、携帯電話のセキュリティ対策を設定してください。 - 保管庫には、携帯電話のセキュリティ対策(パスワードまたは生体認証) を使ってアクセスします。\n\n暗号化キーは保管庫内でアクセスすることができ、保管庫を復元するために使用されます。 - 保管庫には、携帯電話のセキュリティ対策(パスワードまたは生体認証)を使用してアクセスします。\n\n保管庫の内容は、AES256暗号化アルゴリズムを使用して暗号化されます。\n暗号化キーは保管庫内部にはアクセスできず、保管庫を転送することはできません。 - 保管庫 \"%1$s\" はすでに存在します 非表示 ロケーション ファイル名を更新する - プロジェクトをサポートする + Support ReFra クリックしてコピー オーディオフォーカス これを有効にすると、メディア再生時にオーディオフォーカスがかかります。バックグラウンドで再生中のメディアは、ビデオ再生中に一時停止します。(アプリの再起動が必要です) @@ -282,7 +266,6 @@ 必要ありません 必要ないので、非表示にしてください。\n(設定から再度有効にできます) 復元 - 暗号化されています 日付の形式 日付の形式 独自の日付形式を設定 @@ -312,21 +295,7 @@ フィルター マークアップ サイズ - メディアの暗号化中... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - 保管庫の復号 - 現在のデータ保管庫とそのコンテンツは復号され、復元されます。\nこの操作により、ギャラリーのコンテンツに再びアクセスできるようになります。 - 保管庫を復号しますか? はい - 保管庫を削除しますか? - 現在の保管庫と保管庫に含まれるコンテンツは完全に削除されます。\nこの操作は取り消せません。 - 新しい保管庫を作成しますか? - 保管庫には、携帯電話のセキュリティ対策(パスワードまたは生体認証) を使ってアクセスします。\n\n暗号化キーは保管庫内でアクセスすることができ、保管庫を復元するために使用されます。 - 保管庫を選択 撮影日時 Resolution Dimensions diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 1121e3ed49..d0ed60483f 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -1,6 +1,6 @@ - ReFra + 갤러리 앨범 오늘 @@ -52,9 +52,9 @@ 타임라인 미디어 위치 위치 지도 - %s 제작 + Based on ReFra by %s IacobIonut01 - 기부하기 + Donate to ReFra Github 기부 버튼 Github 버튼 @@ -110,7 +110,6 @@ 메타데이터 편집 Moving to Trash Deleting Permanently - %1$s개 항목 Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ 복사 Ignored Albums 앨범 선택 - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ 모든 파일 관리 "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" 모든 파일 관리 허용 - Vault 도구 Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-lt-rLT/strings.xml b/app/src/main/res/values-lt-rLT/strings.xml index 5253bba6c2..9011614fd5 100644 --- a/app/src/main/res/values-lt-rLT/strings.xml +++ b/app/src/main/res/values-lt-rLT/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerija Albumai Šiandien @@ -52,9 +52,9 @@ Išklotinė Medijos sukūrimo vieta Vietovės žemėlapis - Sukūrė %s + Based on ReFra by %s IacobIonut01 - Paremti + Donate to ReFra Github Paramos mygtukas „Github“ mygtukas @@ -110,7 +110,6 @@ Redaguoti metaduomenis Perkėlimas į šiukšliadėžę Ištrynimas visam laikui - %1$s elementų Perkeliama %1$s elementų į šiukšliadėžę... Ištrinama %1$s elementų... Ilgai palaikykite nuspaudę, kad ištrintumėte @@ -179,18 +178,10 @@ Leisti redaguoti visus failus "Leidžia programėlei kurti, redaguoti ir šalinti failus bei albumus iš vietų kurių sistema anksčiau neleido pasiekti. (Įtraukiami albumai, esantys kituose aplankuose nei Pictures ir DCIM, taip pat albumai iš SD kortelės)" Leisti redaguoti visus failus - Saugykla Įrankiai Biblioteka Biometrinis autentifikavimas - Atrakinti saugyklą - Pridėti medijos failų į Saugyklą - Saugykla %1$s (%2$s) jau egzistuoja Suteikta - Nauja saugykla - Ištrinti saugyklą - Nežinoma saugykla - Paslėpti Pasirinkite, albumus kurių nenorėtumėte matyti pagrindinėje išklotinėje, skirtuke Albumai arba abiejuose.\n\nGalite pasirinkti atskirus albumus arba sukurti šabloną pagal kurį automatiškai bus paslėpti visi jį atitinkantys albumai. Nėra paslėptų albumų Nustatyti albumų paslėpimą @@ -250,17 +241,10 @@ Paslėpti skilčių juostą paslinkus žemyn Automatiškai palėpti skilčių juostą paslinkus žemyn Naršymas - Nustatykite savo saugyklą - - Saugyklos pavadinimas - Prieš sukurdami saugyklą, nustatykite telefono užrakinimo būdą. - Į saugyklą bus galima patekti naudojant telefono apsaugos priemones (slaptažodį arba biometrinius duomenis)\n\nŠifravimo raktą galima rasti saugykloje ir jis bus naudojamas norint atkurti bet kurią saugyklą. - Į saugyklą bus galima patekti naudojant telefono apsaugos priemones (slaptažodį arba biometrinius duomenis)\n\nSaugyklos turinys yra užšifruotas naudojant AES256 šifravimo algoritmą.\nŠifravimo raktas nėra pasiekiamas atrakinus saugyklą ir jūs negalėsite perkelti savo saugyklos. - Saugykla „%1$s“ jau egzistuoja Praleista Vietovė Atnaujinti failo pavadinimą - Paremti šį projektą + Support ReFra Spustelėkite norėdami nukopijuoti Suteikti prioritetą programėlės garsui Programėlės garsui bus skirtas prioritetas. Fone grojanti medija bus pristabdyta, kol pasibaigs rodomas vaizdo įrašas. (Reikia iš naujo paleisti programą) @@ -282,7 +266,6 @@ Nenoriu to Aš to nenoriu, pašalinkite tai.\n(Tai galima vėl įjungti nustatymuose) Atkurti - Užšifruota Datos formatas Datos formatavimas Sukurkite savo norimą datos formatą @@ -312,21 +295,7 @@ Filtrai Žymėti Dydis - Medijos failai užšifruojami... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Iššifruoti saugyklą - Dabartinė saugykla ir jos turinys bus iššifruotas ir atkurtas atgal.\nAtlikus šį veiksmą jūsų turinys vėl bus prieinamas Galerijoje. - Ar tikrai norite iššifruoti? Taip - Ar tikrai norite ištrinti? - Dabartinė saugykla ir jos turinys bus visam laikui ištrinti.\nŠis veiksmas yra negrįžtamas. - Sukurti naują saugyklą? - Į saugyklą bus galima patekti naudojant telefono apsaugos priemones (slaptažodį arba biometrinius duomenis)\n\nŠifravimo raktą galima rasti saugykloje ir jis bus naudojamas norint atkurti bet kurią saugyklą. - Pasirinkite saugyklą Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ne-rNP/strings.xml b/app/src/main/res/values-ne-rNP/strings.xml index 72557489f0..444195b64b 100644 --- a/app/src/main/res/values-ne-rNP/strings.xml +++ b/app/src/main/res/values-ne-rNP/strings.xml @@ -1,6 +1,6 @@ - ReFra + ग्यालेरी Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml index 1b598b3390..c191d1888c 100644 --- a/app/src/main/res/values-nl-rNL/strings.xml +++ b/app/src/main/res/values-nl-rNL/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerij Albums Vandaag @@ -52,9 +52,9 @@ Tijdlijn Media locatie Kaartlocatie - door %s + Based on ReFra by %s IacobIonut01 - Doneer + Donate to ReFra Github Doneer knop Github-knop @@ -110,7 +110,6 @@ Metagegevens bewerken Verplaatsen naar prullenbak Permanent verwijderen - %1$s items %1$s Items naar de prullenbak.. %1$s items verwijderen... Lang indrukken om te verwijderen @@ -179,18 +178,10 @@ Alle bestanden beheren "Staat de app toe om bestanden en albums aan te maken, te wijzigen en te verwijderen die eerder beperkt waren door het systeem. (Inclusief albums buiten de mappen Afbeeldingen en DCIM en de albums van de SD-kaart" Toestaan om alle bestanden te beheren - Kluis Tools Bibliotheek Biometrische verificatie - Ontgrendel je kluis - Media toevoegen aan kluis - Kluis %1$s (%2$s) bestaat al Verleend - Nieuwe kluis - Verwijder kluis - Onbekende kluis - Verbergen Selecteer welke albums je niet wilt zien in de hoofdtijdlijn, in het tabblad Albums of beide.\n\nJe kunt individuele albums selecteren of een wildcard aanmaken om automatisch alle albums te verbergen die hiermee overeenkomen. Geen genegeerde albums Genegeerde albums instellen @@ -250,17 +241,10 @@ Verberg navigatiebalk bij scrollen De navigatiebalk automatisch verbergen tijdens het scrollen Navigatie - Uw kluis instellen - - Naam kluis - Stel een schermbeveiliging in voordat u de kluis instelt. - De kluis is toegankelijk via de beveiligingsmaatregelen van je telefoon (wachtwoord of biometrie)\n\n. De coderingssleutel is toegankelijk in de kluis en wordt gebruikt om de kluis te herstellen. - De kluis is toegankelijk via de beveiligingsmaatregelen van je telefoon (wachtwoord of biometrische gegevens)\n\n. De inhoud van de kluis wordt versleuteld met een AES256-encryptie-algoritme.\nDe coderingssleutel is niet toegankelijk in de kluis en u kunt uw kluis niet overdragen. - Kluis \"%1$s\" bestaat al Genegeerd Locatie Bestandsnaam bijwerken - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-nn-rNO/strings.xml b/app/src/main/res/values-nn-rNO/strings.xml index aade1811f3..c2418ef5a3 100644 --- a/app/src/main/res/values-nn-rNO/strings.xml +++ b/app/src/main/res/values-nn-rNO/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleri Album I dag @@ -52,9 +52,9 @@ Tidslinje Media Location Location Map - er laga av %s + Based on ReFra by %s IacobIonut01 - Doner + Donate to ReFra Github Doner-knapp Github-knapp @@ -84,7 +84,7 @@ Bilete Video Lukk - Gje ReFra løyve til å handsame medium + Gje Gallery løyve til å handsame medium This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Hopp over Tillat @@ -110,7 +110,6 @@ Rediger metadata Flytt til papirkorga Deleting Permanently - %1$s items Flyttar %1$s element til papirkorga … Deleting %1$s items... Trykk lenge for å fjerna @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Kvelv Tools Library Godkjenning med biometri - Lås opp kvelvet - Legg til medium i kvelvet - Kvelvet %1$s (%2$s) finst frå før Granted - Nytt kvelv - Slett kvelv - Ukjent kvelv - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigering - Set opp kvelvet - - Namn på kvelvet - For å oppretta kvelvet må du setja opp eininga sin tryggleiksfunksjon. - Du låser opp kvelvet ved å bruka eininga sine tryggleiksfunksjonar (passord eller biometri)\n\nKrypteringsnøkkelen vert tilgjengeleg i kvelvet, og vert brukt ved gjenoppretting av kvelv. - Du låser opp kvelvet ved å bruka eininga sine tryggleiksfunksjonar (passord eller biometri)\n\nInnhaldet i kvelvet er krypterte med ein AES256-krypteringsalgoritme.\nKrypteringsnøkkelen vert ikkje tilgjengeleg i kvelvet, og du kan ikkje overføra kvelvet. - Kvelvet «%1$s» finst frå før Ignored Location Update file name - Support the project + Support ReFra Click to copy Fokuser lyd Viss det er kryssa av her, tek appen over lydfokus ved avspeling av medium. Bakgrunnsmedium vert satt på pause under avspeling av videoar. (Appen må startast på nytt). @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Kryptert Datoformat Datoformat Set opp datoformat @@ -312,21 +295,7 @@ Filter Marker Storleik - Krypterer medium … - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Dekrypter kvelvet - Det gjeldande kvelvet og innhaldet i det vert dekryptert og gjenoppretta.\nInnhaldet vert på nytt tilgjengeleg i ReFra. - Vil du dekryptera? Ja - Vil du sletta? - Det gjeldande kvelvet og innhaldet i det vert sletta permanent.\nDu kan ikkje angra denne handlinga. - Oppretta kvelv? - Du låser opp kvelvet ved å bruka eininga sine tryggleiksfunksjonar (passord eller biometri)\n\nKrypteringsnøkkelen vert tilgjengeleg i kvelvet, og vert brukt ved gjenoppretting av kvelv. - Vel eit kvelv Taken on Oppløysing Dimensions diff --git a/app/src/main/res/values-no-rNO/strings.xml b/app/src/main/res/values-no-rNO/strings.xml index 0776102e2e..d851ab080d 100644 --- a/app/src/main/res/values-no-rNO/strings.xml +++ b/app/src/main/res/values-no-rNO/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleri Album I dag @@ -52,9 +52,9 @@ Tidslinje Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Bilde Video Lukk - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Hopp over Tilllat @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 72557489f0..be238a59d3 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-pa-rIN/strings.xml b/app/src/main/res/values-pa-rIN/strings.xml index 72557489f0..be238a59d3 100644 --- a/app/src/main/res/values-pa-rIN/strings.xml +++ b/app/src/main/res/values-pa-rIN/strings.xml @@ -1,6 +1,6 @@ - ReFra + Gallery Albums Today @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index e48d892374..5c886473ea 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeria Albumy Dzisiaj @@ -52,9 +52,9 @@ Oś czasu Lokalizacja nośników Mapa lokalizacji - przez %s + Based on ReFra by %s IacobIonut01 - Wesprzyj + Donate to ReFra Github Przycisk Wsparcia Przycisk Github @@ -110,7 +110,6 @@ Edytuj metadane Przenoszenie do kosza Trwa Trwałe Usuwanie - %1$s pozycji Usuwanie %1$s elementów.. Usuwanie %1$s elementów... Przytrzymaj, aby usunąć @@ -179,18 +178,10 @@ Zarządzanie wszystkimi plikami "Umożliwia aplikacji tworzenie, modyfikowanie i usuwanie plików i albumów, które były wcześniej ograniczone przez system. (Obejmuje albumy spoza folderów Pictures i DCIM oraz z karty SD)" Zezwól na zarządzanie wszystkimi plikami - Sejf Narzędzia Biblioteka Uwierzytelnianie biometryczne - Odblokuj sejf - Dodaj media do sejfu - Sejf %1$s (%2$s) istnieje Udzielone - Nowy sejf - Usuń sejf - Nieznany Sejf - Schowaj Wybierz albumy, których nie chcesz zobaczyć w głównej osi czasu, w zakładce Albumy lub w obu tych kategoriach.\n\nMożesz wybrać poszczególne albumy lub utworzyć wzór wieloznaczny, aby automatycznie ukryć wszystkie albumy, które pasują do nich. Nie zignorowano albumów Konfiguracja zignorowanych albumów @@ -250,17 +241,10 @@ Ukryj pasek nawigacji podczas przewijania Automatyczne ukrywaj pasek nawigacji podczas przewijania Nawigacja - Skonfiguruj swój sejf - - Nazwa sejfu - Przed skonfigurowaniem sejfu proszę ustawić środek bezpieczeństwa telefonu. - Sejf będzie dostępny przy użyciu środków bezpieczeństwa telefonu (hasło lub biometryczne)\n\nKlucz szyfrowania może być dostępny wewnątrz sejfu i będzie używany do przywrócenia wszelkich sejfów. - Dostęp do sejfu będzie możliwy przy użyciu środków bezpieczeństwa telefonu (hasło lub biometryczne)\n\nZawartość sejfu jest zaszyfrowana przy użyciu algorytmu szyfrowania AES256.\nKlucz szyfrowania nie może być dostępny wewnątrz sejfu i nie będziesz mógł przenieść swojego sejfu. - Sejf \"%1$s\" już istnieje Ignorowane Miejsce Zaktualizuj nazwę pliku - Wesprzyj projekt + Support ReFra Kliknij, aby skopiować Zrób fokus audio Włączenie tej opcji spowoduje skupienie się na dźwięku podczas odtwarzania multimediów. Wszelkie multimedia odtwarzane w tle zostaną wstrzymane podczas odtwarzania wideo. (Wymagane ponowne uruchomienie aplikacji) @@ -282,7 +266,6 @@ Nie chcę tego Nie chcę tego, proszę to usunąć.\n(Można to włączyć ponownie w ustawieniach) Przywróć - Zaszyfrowane Format Daty Formaty Dat Ustaw własne formaty dat @@ -312,21 +295,7 @@ Filtry Znacznik Rozmiar - Szyfrowanie multimediów... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Odszyfruj Sejf - Obecny sejf i jego zawartość będzie odszyfrowana i odzyskana.\nTa akcja spowoduje, że twoje multimedia znowu będą dostępne poza aplikacją. - Potwierdzić Odszyfrowywanie? Tak - Potwierdzić Usunięcie? - Ten sejf będzie trwale usunięty wraz z jego zawartością.\nTa akcja jest nieodwracalna. - Utworzyć nowy sejf? - Do sejfu można się dostać używając zabezpieczeń telefonu (hasło lub biometria)\n\nKlucz szyfrujący można znaleźć wewnątrz sejfu i można go użyć, aby odzyskać dowolny sejf. - Wybierz sejf Taken on Rozdzielczość Dimensions diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7aa42cfacb..b1e9c03260 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeria Álbuns Hoje @@ -52,9 +52,9 @@ Linha do tempo Localização da mídia Mapa de localização - por %s + Based on ReFra by %s IacobIonut01 - Doar + Donate to ReFra GitHub Botão de Doação Botão github @@ -84,7 +84,7 @@ Foto Vídeo Fechar - Permita que ReFra gerencie sua mídia + Permita que Gallery gerencie sua mídia Esta configuração simplesmente ignora quaisquer diálogos de confirmação extras ao executar várias ações, como marcar como favorito. Pular Permitir @@ -110,7 +110,6 @@ Editar metadados Mover para a lixeira Exclusão permanente - %1$s itens Movendo %1$s itens para a lixeira... Excluindo %1$s itens... Toque longo para remover @@ -179,18 +178,10 @@ Gerenciar Todos os Arquivos "Permite que o aplicativo crie, modifique e exclua arquivos e álbuns que antes eram restritos pelo sistema. (Inclui álbuns fora das pastas Pictures e DCIM e os do cartão SD" Permitir Gerenciar Todos os Arquivos - Cofre Ferramentas Biblioteca Autenticação Biométrica - Desbloqueie seu Cofre - Adicionar Mídia ao Cofre - Existe o cofre %1$s (%2$s) Concedido - Novo Cofre - Excluir Cofre - Cofre Desconhecido - Ocultar Selecione quais álbuns você não deseja ver na linha do tempo principal, na guia Álbuns ou em ambas.\n\nVocê pode selecionar álbuns individuais ou criar um padrão curinga para ocultar automaticamente qualquer álbum que corresponda a ele. Nenhum álbum ignorado Configurar álbuns ignorados @@ -250,17 +241,10 @@ Ocultar a barra de navegação na rolagem Oculte automaticamente a barra de navegação durante a rolagem Navegação - Configure seu cofre - - Nome do Cofre - Por favor, configure uma medida de segurança do telefone antes de configurar o cofre. - O cofre será acessado usando as medidas de segurança do seu telefone (senha ou biometria)\n\nA chave de criptografia pode ser acessada dentro do cofre e será usada para restaurar qualquer cofre. - O cofre será acessado usando as medidas de segurança do seu telefone (senha ou biometria)\n\nO conteúdo do cofre é criptografado usando um algoritmo de criptografia AES256.\nA chave de criptografia não pode ser acessada dentro do cofre e você não poderá transferir seu cofre. - O cofre \"%1$s\" já existe Ignorado Local Atualizar o nome do arquivo - Apoie o projeto + Support ReFra Clique para copiar Assumir Foco do Áudio Ativando isso fará com que o áudio assuma o controle ao reproduzir mídia. Qualquer mídia reproduzida em segundo plano será pausada enquanto o vídeo estiver sendo reproduzido. (É necessário reiniciar o aplicativo) @@ -282,7 +266,6 @@ Eu não quero isso Eu não quero isso, por favor, remova-o.\n(Pode ser ativado novamente nas configurações) Restaurar - Criptografado Formato de data Formatos de data Defina seus próprios formatos de data personalizados @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 7d7982944a..9aef429626 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeria Albums Today @@ -52,9 +52,9 @@ Linha Cronológica Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ro-rRO/strings.xml b/app/src/main/res/values-ro-rRO/strings.xml index 355254fc56..a82380ef83 100644 --- a/app/src/main/res/values-ro-rRO/strings.xml +++ b/app/src/main/res/values-ro-rRO/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galerie Albume Astăzi @@ -52,9 +52,9 @@ Cronologie Locație media Harta de amplasare - de %s + Based on ReFra by %s IacobIonut01 - Donează + Donate to ReFra GitHub Butonul de donație Butonul GitHub @@ -110,7 +110,6 @@ Editează metadatele Se mută la Gunoi Ștergere permanentă - %1$s articole Se șterg %1$s elemente.. Se șterg %1$s elemente... Apasă lung pentru a șterge @@ -179,18 +178,10 @@ Gestionează toate fișierele "Permite aplicației să creeze, să modifice și să șteargă fișiere și albume care au fost restricționate anterior de sistem. (Include albumele din afara dosarelor Pictures și DCIM și cele de pe cardul SD)" Permite gestionarea tuturor fișierelor - Seif Instrumente Bibliotecă Autentificare biometrică - Deblochează seiful - Adaugă media în Seif - Seiful %1$s (%2$s) există Acordat - Seif nou - Șterge seiful - Seif necunoscut - Ascunde Selectează ce albume ai dori să excluzi din Cronologie, din secțiunea de Albume sau amândouă.\n\nPoti selecta albume individuale sau poți crea un șablon cu metacaractere pentru a ascunde automat orice album se potrivește cu șablonul. Nu există albume ignorate Configurare albume ignorate @@ -250,17 +241,10 @@ Ascunde bara de navigare la derulare Ascunde automat bara de navigare în timpul derulării Navigare - Configurarea seifului - - Nume seif - Please set-up a phone security measure before setting up the vault. - Seiful va fi accesat folosind măsurile de securitate ale telefonului (parolă sau biometrie)\n\nCheia de criptare poate fi accesată în interiorul seifului și va fi utilizată pentru a restaura seifurile. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Seiful \"%1$s\" există deja Ignorat Locație Actualizează numele fișierului - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ru-rBY/strings.xml b/app/src/main/res/values-ru-rBY/strings.xml index a1ed20eccd..8dc8838d5b 100644 --- a/app/src/main/res/values-ru-rBY/strings.xml +++ b/app/src/main/res/values-ru-rBY/strings.xml @@ -1,6 +1,6 @@ - ReFra + Галерея Альбомы Сёння @@ -52,9 +52,9 @@ Timeline Media Location Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -84,7 +84,7 @@ Photo Video Close - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -110,7 +110,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -154,9 +153,9 @@ Copy Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override @@ -179,18 +178,10 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index acd2b6df62..e17fdaf171 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -1,6 +1,6 @@ - ReFra + Галерея Альбомы Сегодня @@ -52,9 +52,9 @@ Хронология Расположение медиа Карта - от %s + Based on ReFra by %s IacobIonut01 - Пожертвовать + Donate to ReFra GitHub Кнопка пожертвования Кнопка GitHub @@ -110,7 +110,6 @@ Редактировать метаданные Перемещение в Корзину Безвозвратное удаление - %1$s элементов Перемещение в корзину %1$s элементов... Удаление %1$s элементов... Зажмите и удержите, чтобы удалить @@ -179,18 +178,10 @@ Управление всеми файлами "Позволяет приложению создавать, изменять и удалять файлы и альбомы, доступ к которым ранее был ограничен системой. (Включая альбомы вне папок Pictures и DCIM, а также альбомы с SD-карты)" Разрешить управлять всеми файлами - Сейф Инструменты Библиотека Биометрическая аутентификация - Разблокировать сейф - Добавить медиа в сейф - Сейф %1$s (%2$s) существует Предоставлено - Новый Сейф - Удалить Сейф - Неизвестный сейф - Скрыть Выберите альбомы, которые вы не хотите видеть на основной хронологии, на вкладке \"Альбомы\" или на обеих вкладках.\n\nВы можете выбрать отдельные альбомы или создать шаблон чтобы автоматически скрыть все альбомы, которые ему соответствуют. Нет игнорируемых альбомов Настроить игнорируемые альбомы @@ -250,17 +241,10 @@ Скрывать панель навигации при прокрутке Автоматически скрывает панель навигации при прокрутке Навигация - Настройка сейфа - - Название сейфа - Перед настройкой сейфа установите меры безопасности на телефоне. - Доступ к сейфу будет осуществляться с помощью мер безопасности вашего телефона (пароль или биометрические данные)\n\nКлюч шифрования может быть доступен внутри хранилища и будет использоваться для восстановления других хранилищ. - Доступ к хранилищу осуществляется с помощью мер безопасности вашего телефона (пароль или биометрические данные)\n\nСодержимое хранилища зашифровано с помощью алгоритма шифрования AES256.\nДоступ к ключу шифрования внутри хранилища невозможен, и вы не сможете передать хранилище. - Сейф \"%1$s\" уже существует Игнорированное Путь Изменить имя файла - Поддержать проект + Support ReFra Нажмите, чтобы скопировать Забирать аудио фокус Когда включено, аудио фокус будет переключен при проигрывании медиа. Медиа проигрываемое в фоне остановится пока видео воспроизводится. (Требуется перезапуск приложения) @@ -282,7 +266,6 @@ Я не хочу это Я не хочу это, уберите это.\n(Может быть включено опять в настройках) Восстановить - Зашифровано Формат времени Форматы дат Установите свои форматы дат @@ -312,21 +295,7 @@ Фильтры Разметка Размер - Шифрование медиа... - Шифрование медиа - Расшифровка медиа - %1$d%% завершено - Операции в сейфе - Прогресс задач шифрования / расшифровки - Расшифровка сейфа - Текущий сейф и его содержимое будет расшифровано и восстановлено.\nЭто действие снова сделает ваш контент доступным в Галерее. - Подтвердить расшифровку? Да - Подтвердить удаление? - Текущий сейф и его содержимое будет удалено навсегда.\nЭто действие нельзя отменить. - Создать новый сейф? - Доступ к сейфу будет осуществляться с использованием мер безопасности вашего телефона (пароль или биометрические данные)\n\nКлюч шифрования доступен внутри хранилища и будет использоваться для восстановления любых сейфов. - Выберите сейф Дата съёмки Разрешение Соотношение сторон diff --git a/app/src/main/res/values-sr-rSP/strings.xml b/app/src/main/res/values-sr-rSP/strings.xml index b463ed6abe..63e6346ff9 100644 --- a/app/src/main/res/values-sr-rSP/strings.xml +++ b/app/src/main/res/values-sr-rSP/strings.xml @@ -1,6 +1,6 @@ - ReFra + Галерија Албуми Данас @@ -52,9 +52,9 @@ Временска линија Локација медија Мапа локације - од %s + Based on ReFra by %s IacobIonut01 - Донирај + Donate to ReFra GitHub Дугме „Донирај” Дугме „GitHub” @@ -110,7 +110,6 @@ Измените метаподатке Премештање у смеће Трајно брисање - %1$s предмета Бацање у смеће %1$s предмета... Брисање %1$s предмета... Дуго притисните да бисте уклонили @@ -179,18 +178,10 @@ Управљање свим фајловима "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Дозволи управљање свим фајловима - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index bc0b1502aa..7742dd14df 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galleri Album I dag @@ -52,9 +52,9 @@ Tidslinje Medieplats Platskarta - av %s + Based on ReFra by %s IacobIonut01 - Donera + Donate to ReFra Github Donera-knapp Github-knapp @@ -84,7 +84,7 @@ Foto Video Stäng - Tillåt att ReFra hanterar dina medier + Tillåt att Gallery hanterar dina medier Denna inställning hoppar bara över eventuella ytterligare bekräftelsedialogrutor när diverse handlingar, såsom att markera som favorit, utförs. Hoppa över Tillåt @@ -110,7 +110,6 @@ Redigera metadata Flyttar till skräp Permanent radering - %1$s objekt Slänger %1$s objekt ... Raderar %1$s objekt ... Långtryck för att ta bort @@ -179,18 +178,10 @@ Hantera alla filer "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Tillåt att hantera alla filer - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -250,17 +241,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e116b64cf0..6b761609e7 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -1,6 +1,6 @@ - ReFra + Galeri Albümler Bugün @@ -52,9 +52,9 @@ Zaman Çizelgesi Medya Konumu Konum Haritası - %s tarafından + Based on ReFra by %s IacobIonut01 - Bağış yap + Donate to ReFra GitHub Bağış Düğmesi GitHub Butonu @@ -110,7 +110,6 @@ Meta Veriyi Düzenle Çöpe Taşınıyor Kalıcı Olarak Siliniyor - %1$s öğe %1$s öğe çöp kutusuna taşınıyor.. %1$s öğe siliniyor... Kaldırmak için uzun basın @@ -179,18 +178,10 @@ Tüm Dosyaları Yönet "Uygulamanın, sistem tarafından daha önce kısıtlanmış dosya ve albümleri oluşturmasına, değiştirmesine ve silmesine izin verir. (Resimler ve DCIM klasörleri dışındaki albümleri ve SD Kart'takileri içerir)" Tüm Dosyaların Yönetilmesine İzin Ver - Kasa Araçlar Kütüphane Biyometrik Kimlik Doğrulama - Kasınızı Kilidini Açın - Medyayı Kasaya Ekle - Kasa %1$s (%2$s) zaten mevcut Verildi - Yeni Kasa - Kasayı Sil - Bilinmeyen Kasa - Gizle Ana Zaman Çizelgesinde, Albümler sekmesinde veya her ikisinde de görmek istemediğiniz albümleri seçin.\n\nBireysel albümleri seçebilir veya eşleşen herhangi bir albümü otomatik olarak gizlemek için bir joker karakter deseni oluşturabilirsiniz. Yoksayılan albüm yok Yoksayılan albümleri ayarla @@ -250,17 +241,10 @@ Kaydırma sırasında navigasyon çubuğunu gizle Kaydırma sırasında navigasyon çubuğunu otomatik olarak gizle Gezinme - Kasınızı ayarlayın - - Kasa Adı - Kasayı ayarlamadan önce lütfen bir telefon güvenlik önlemi ayarlayın. - Kasa, telefon güvenlik önlemleriniz (şifre veya biyometrik) kullanılarak erişilecektir.\n\nŞifreleme anahtarı kasanın içinde erişilebilir ve herhangi bir kasayı geri yüklemek için kullanılacaktır. - Kasa, telefon güvenlik önlemleriniz (şifre veya biyometrik) kullanılarak erişilecektir.\n\nKasa içeriği AES256 şifreleme algoritması kullanılarak şifrelenir.\nŞifreleme anahtarı kasanın içinde erişilemez ve kasanızı aktaramazsınız. - \"%1$s\" adlı kasa zaten mevcut Yoksayıldı Konum Dosya adını güncelle - Projeyi destekleyin + Support ReFra Kopyalamak için tıklayın Ses Odaklanmasını Al Bunu etkinleştirmek, medya oynatılırken ses odağını alır. Arka planda çalan herhangi bir medya, video oynatılırken duraklatılır. (Uygulamanın yeniden başlatılması gerekir) @@ -282,7 +266,6 @@ Bunu istemiyorum Bunu istemiyorum, lütfen kaldırın.\n(Ayarlardan tekrar etkinleştirilebilir) Geri yükle - Şifrelenmiş Tarih Biçimi Tarih Biçimleri Kendi özel tarih formatınızı belirleyin @@ -312,21 +295,7 @@ Filtreler İşaretleme Boyut - Medya şifreleniyor... - Medya şifreleniyor - Medya şifresi çözülüyor - %1$d%% tamamlandı - Kasa İşlemleri - Şifreleme / şifre çözme görevlerinin ilerlemesi - Kasayı Çöz - Mevcut kasa ve içeriği çözülecek ve geri yüklenecektir.\nBu işlem, içeriğinizi Galeride tekrar erişilebilir hale getirecektir. - Çözme Onaylansın mı? Evet - Silme Onaylansın mı? - Mevcut kasa ve içeriği kalıcı olarak silinecektir.\nBu işlem geri alınamaz. - Yeni kasa oluşturulsun mu? - Kasa, telefon güvenlik önlemleriniz (şifre veya biyometrik) kullanılarak erişilecektir.\n\nŞifreleme anahtarı kasanın içinde erişilebilir ve herhangi bir kasayı geri yüklemek için kullanılacaktır. - Bir kasa seçin Çekildiği tarih Çözünürlük Boyutlar diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index c1bf06a9f2..f3c1da5075 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -1,6 +1,6 @@ - ReFra + Галерея Альбоми Сьогодні @@ -52,9 +52,9 @@ Хронологія Розташування медіафайлу Мапа розташування - від %s + Based on ReFra by %s IacobIonut01 - Пожертвувати + Donate to ReFra GitHub Кнопка пожертви Кнопка GitHub @@ -110,7 +110,6 @@ Редагувати метадані Переміщення до кошика Остаточне видалення - %1$s елементів Переміщення у кошик %1$s елементів... Видалення %1$s елементів... Утримуйте, щоби видалити @@ -179,18 +178,10 @@ Керування всіма файли "Дозволяє застосунку створювати, змінювати та видаляти файли та альбоми, які попередньою заборонені системою. (Включає альбоми за межами тек «Зображення», DCIM і SD-карт)" Дозволити керувати всіма файлами - Сховище Інструменти Бібліотека Біометрична перевірка - Розблокувати сховище - Додати медіафайли до сховища - Сховище «%1$s» (%2$s) існує Надано - Нове сховище - Видалити сховище - Невідоме сховище - Сховати Виберіть альбоми, які ви не хочете бачити на головній хронології, на вкладці «Альбоми» або і там, і там.\n\nВи можете вибрати окремі альбоми або створити шаблон, щоб автоматично приховати всі альбоми, які йому відповідають. Немає ігнорованих альбомів Налаштувати ігноровані альбоми @@ -250,17 +241,10 @@ Приховати панель навігації під час прокручування Автоматично приховувати панель навігації під час прокручування Навігація - Налаштувати сховище - - Назва сховища - Налаштуйте захист телефона перед налаштуванням сховища. - Доступ до сховища буде здійснюватися за допомогою заходів безпеки вашого телефона (пароль або біометричні дані)\n\nКлюч шифрування доступний всередині сховища і буде використовуватися для відновлення будь-яких сховищ. - Доступ до сховища буде здійснюватися за допомогою заходів безпеки вашого телефону (пароль або біометричні дані)\n\nВміст сховища зашифровано за допомогою алгоритму шифрування AES256.\nКлюч шифрування не доступний всередині сховища, і не зможете передати своє сховище. - Сховище «%1$s» вже існує Ігнорується Розташування Оновити назву файлу - Підтримати проєкт + Support ReFra Натисніть, щоб скопіювати Отримання фокусу звуку Якщо увімкнути цей параметр, під час відтворення медіафайлів фокус буде зосереджено на звуці. Будь-який медіафайл, що відтворюється у фоновому режимі, буде призупинено під час відтворення відео. (Потрібно перезапустити застосунок) @@ -282,7 +266,6 @@ Я не хочу цього Я не хочу цього, приберіть це.\n(Його можна знову ввімкнути в налаштуваннях) Відновити - Зашифровано Формат дати Формати дат Налаштуйте власні формати дат @@ -312,21 +295,7 @@ Фільтри Розмітка Розмір - Шифрування медіа... - Шифрування медіа - Розшифрування медіа - Завершено на %1$d%% - Операції зі сховищем - Хід виконання завдань шифрування / розшифрування - Розшифрування сейфу - Поточний сейф та його вміст буде розшифровано та відновлено.\nЦя дія знову зробить ваш контент доступним у Галереї. - Підтвердити розшифрування? Так - Підтвердити видалення? - Поточний сейф та його вміст буде видалено назавжди.\nЦю дію не можна скасувати. - Створити новий сейф? - Доступ до сейфа буде здійснюватися за допомогою заходів безпеки вашого телефону (пароль або біометричні дані)\n\nКлюч шифрування доступний всередині сховища і використовуватиметься для відновлення будь-яких сейфів. - Виберіть сейф Знято Роздільність Розміри diff --git a/app/src/main/res/values-vi-rVN/strings.xml b/app/src/main/res/values-vi-rVN/strings.xml index 3d565ff39c..93b7198827 100644 --- a/app/src/main/res/values-vi-rVN/strings.xml +++ b/app/src/main/res/values-vi-rVN/strings.xml @@ -1,6 +1,6 @@ - ReFra + Thư viện Album Hôm nay @@ -52,9 +52,9 @@ Dòng thời gian Vị trí phương tiện Bản đồ vị trí - bởi %s + Based on ReFra by %s IacobIonut01 - Ủng hộ + Donate to ReFra Github Nút ủng hộ Nút Github @@ -84,7 +84,7 @@ Ảnh Video Đóng - Cho phép ReFra quản lý phương tiện của bạn + Cho phép Gallery quản lý phương tiện của bạn Thiết đặt này chỉ bỏ qua mọi hộp thoại xác nhận bổ sung trong khi thực hiện các hành động khác nhau như đánh dấu là ưa thích. Bỏ qua Cho phép @@ -110,7 +110,6 @@ Chỉnh sửa siêu dữ liệu Dời vô sọt rác Xóa vĩnh viễn - %1$s mục Đang dời %1$s mục vào sọt rác.. Đang xóa %1$s mục... Nhấn giữ để loại bỏ @@ -154,9 +153,9 @@ Sao chép Album bị bỏ qua Chọn một album - Bỏ qua album từ ReFra + Bỏ qua album từ Gallery Thêm vào album bị bỏ qua - Xóa album khỏi hiển thị bên trong ReFra + Xóa album khỏi hiển thị bên trong Gallery Loại khỏi danh sách bị bỏ qua Loại khỏi danh sách bị bỏ qua album có tên %1$s Ghi đè @@ -179,18 +178,10 @@ Quản lý tất cả các tập tin "Cho phép ứng dụng tạo, sửa đổi và xóa các tệp và album trước đây bị hệ thống hạn chế. (Bao gồm các album bên ngoài thư mục Pictures và DCIM và các album từ Thẻ SD)" Cho phép quản lý tất cả các tệp tin - Kho Công cụ Thư viện Xác thực sinh trắc học - Mở Kho của bạn - Thêm phương tiện vào Kho - Kho %1$s (%2$s) tồn tại Đã cấp quyền - Kho mới - Dẹp kho - Kho lạ - Giấu Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. Chưa bỏ qua album nào Thêm vào album bị bỏ qua @@ -250,17 +241,10 @@ Giấu thanh điều hướng khi cuộn Tự động giấu thanh điều hướng khi cuộn Điều hướng - Thiết lập kho của bạn - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - The vault will be accessed using your phone security measures (password or biometrics)\n\nThe vault contents are encrypted using a AES256 encryption algorithm.\nEncryption key can\'t be accessed inside the vault and you will not be able to transfer your vault. - Vault \"%1$s\" already exists Bị bỏ qua Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -282,7 +266,6 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -312,21 +295,7 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. - Select a vault Taken on Resolution Dimensions diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 09157f0be1..c9fbd95dbf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,6 +1,6 @@ - ReFra + 图库 专辑 今天 @@ -52,9 +52,9 @@ 时间轴 媒体位置 位置地图 - 由 %s 开发 + Based on ReFra by %s IacobIonut01 - 捐赠 + Donate to ReFra GitHub 捐赠按钮 Github按钮 @@ -110,7 +110,6 @@ 编辑元数据 移至回收站 永久删除 - %1$s 个项目 正在回收 %1$s 个项目.. 正在删除 %1$s 个项目.. 长按移除 @@ -179,18 +178,10 @@ 管理所有文件 "允许应用程序创建、修改和删除之前受系统限制的文件和相册。(包括图片和 DCIM 文件夹以外的相册以及 SD 卡中的相册" 允许管理所有文件 - 私密空间 工具 Library 生物特征认证 - 解锁你的私密空间 - 将媒体添加到私密空间 - 私密空间 %1$s (%2$s) 已存在 同意 - 新的私密空间 - 删除私密空间 - 未知私密空间 - 隐藏 选择您不想在主时间线、专辑选项卡或两者中看到的相册。\n\n您可以选择单个相册,也可以创建通配符模式以自动隐藏与其匹配的任何相册。 没有忽略的相册 设置忽略的相册 @@ -250,17 +241,10 @@ 滚动时隐藏导航栏 滚动时自动隐藏导航栏 导航 - 设置你的私密空间 - - 私密空间名称 - 请在设置保险库前设置手机安全措施。 - 您可以使用手机安全措施(密码或生物识别)访问私密空间\n\n可以在私密空间内部访问加密密钥,并将其用于恢复任何保险库。 - 私密空间通过您的手机安全机制(如密码或生物识别)进行访问。\n\n其存储内容将采用AES256 加密算法进行加密存储。\n温馨提示:加密密钥不会存储在私密空间中,因此您无法访问密钥,也无法将私密空间转移至其它设备。 - 私密空间“%1$s”已存在 忽略 地点 更新文件名 - 支持该项目 + Support ReFra 点击复制 获取音频焦点 启用此功能将在播放媒体时获取音频焦点。播放视频时,后台播放的任何媒体都将暂停。(需要重启应用程序) @@ -282,7 +266,6 @@ 我不想要这个 我不想要这个,请将其移除。\n(在设置中可以重新启用) 恢复 - 加密 日期格式 日期格式 设置您自己的自定义日期格式 @@ -312,21 +295,7 @@ 滤镜 标记 大小 - 正在加密媒体…… - 加密媒体 - 解密媒体 - %1$d%% 已完成 - 私密空间操作 - 加密/解密任务的进度 - 解密私密空间 - 当前保险库及其内容将被解密并恢复。\n此操作将使您的内容在图库中再次可用。 - 确认解密吗? 确认 - 确认删除吗? - 当前保险库及其内容将被永久删除。\n此操作无法撤销。 - 创建新的私密空间? - 保险库将通过您的手机安全措施(密码或生物识别)进行访问。\n\n加密密钥可以在保险库内部获取,并用于恢复任何保险库。 - 请选择私密空间 拍摄时间 分辨率 尺寸 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 752074c904..c6ea9b3304 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,6 @@ - ReFra + 圖片庫 相簿 今天 @@ -52,9 +52,9 @@ 時間軸 媒體位置 位置地圖 - 由 %s 提供 + Based on ReFra by %s IacobIonut01 - 捐贈 + Donate to ReFra GitHub 捐款鼓勵按鈕 GitHub 按鈕 @@ -110,7 +110,6 @@ 編輯相關資訊 移至垃圾桶 永久刪除 - %1$s 個項目 正在移除 %1$s 個項目至垃圾桶... 正在刪除 %1$s 個項目... 長按以移除項目 @@ -179,18 +178,10 @@ 管理所有檔案 "允許此應用程式建立、修改和刪除裝置上的檔案和相簿。(Pictures 和 DCIM 資料夾以外的所有資料夾)" 允許管理所有檔案 - 私密空間 工具 媒體庫 生物辨識 - 解鎖私密空間 - 將媒體新增至私密空間 - 已存在私密空間 %1$s (%2$s) 已授予 - 新增私密空間 - 刪除私密空間 - 未知私密空間 - 隱藏 選擇要在時間軸、相簿標籤或完全隱藏的相簿。\n\n您可手動選擇,或建立萬用字元配對來自動隱藏符合條件的相簿。 無隱藏相簿 設定隱藏相簿 @@ -250,17 +241,10 @@ 隱藏導覽列 捲動時自動隱藏導覽列 導航 - 設定你的私密空間 - - 私密空間名稱 - 請先設定螢幕鎖定。 - 私密空間使用螢幕鎖定解鎖(密碼或生物辨識)。\n\n可於私密空間中存取加密金鑰,供必要時還原無法存取的私密空間。 - 私密空間將透過您的手機安全機制(密碼或生物識別)進行訪問。\n\n私密空間中的儲存內容將使用 AES256 加密演算法進行加密。\n溫馨提示:加密密鑰無法在私密空間內存取,且您將無法轉移您的私密空間。 - 已存在私密空間「%1$s」 已隱藏 地點 更新檔名 - 支援該專案 + Support ReFra 點選複製 取得音訊焦點 啟用此功能將在播放媒體時取得音訊焦點。影片播放時,後台播放的任何媒體都會暫停。(需要重新啟動應用程式) @@ -282,7 +266,6 @@ 我不想要這個 我不想要這個,請將它移除。\n(可以在設定中再次啟用) 恢復 - 加密 日期格式 日期格式 設定您自己的自訂日期格式 @@ -312,21 +295,7 @@ 濾鏡 標記 大小 - 媒體加密中 - 加密媒體 - 解密媒體 - %1$d%% 已完成 - 私密空間操作 - 加密/解密任務的進度 - 解密私密空間 - 目前私密空間及其內容將被解密並還原。\n此動作將使您的內容在相簿中再次可用。 - 確認解密? 確認 - 確認刪除? - 目前的保險庫及其內容將永久刪除。\n此動作無法復原。 - 建立新私密空間? - 私密空間將透過您的手機安全措施(密碼或生物辨識)存取。\n\n加密金鑰可在私密空間內取得,並用於還原任何私密空間。 - 選擇私密空間 裝置 解析度 尺寸 diff --git a/app/src/main/res/values/help_strings.xml b/app/src/main/res/values/help_strings.xml index 37e0cbc750..08be207e67 100644 --- a/app/src/main/res/values/help_strings.xml +++ b/app/src/main/res/values/help_strings.xml @@ -29,7 +29,7 @@ Or drag across photos for quick multi-select Use the action bar to share, delete, or manage Batch actions - Once you\'ve selected items, a bottom action sheet slides up with all available actions: Share, Delete, Copy to album, Move to album, Hide in vault, and more. You can customize which actions appear and their order in Settings > Navigation > Customize selection actions. Tip: to select all photos in a date group, tap the date header. + Once you\'ve selected items, a bottom action sheet slides up with all available actions: Share, Delete, Copy to album, Move to album, Favorite, and more. You can customize which actions appear and their order in Settings > Navigation > Customize selection actions. Tip: to select all photos in a date group, tap the date header. Share photos and videos Share media with other apps and people Share your media @@ -53,7 +53,7 @@ The navigation bar The bottom navigation bar is your main way to move around Gallery. It\'s always visible at the bottom of the screen (unless you enable auto-hide in settings). Each tab remembers where you left off, so switching between sections won\'t lose your scroll position. Three main sections - Timeline shows all your media sorted by date — great for finding recent photos. Albums organizes photos by the folder they\'re stored in (Camera, Screenshots, Downloads, etc.) — perfect for browsing by source. Library is your hub for Favorites, Trash, Vault, Locations, and AI Categories — quick access to everything special. + Timeline shows all your media sorted by date — great for finding recent photos. Albums organizes photos by the folder they\'re stored in (Camera, Screenshots, Downloads, etc.) — perfect for browsing by source. Library is your hub for Favorites, Trash, and AI Categories — quick access to everything special. Browse by albums See photos organized by folder Album view @@ -69,7 +69,7 @@ Search your media Tap the search bar at the top of the timeline to find photos. Basic search matches filenames using fuzzy matching, so you don\'t need to remember exact names. Your recent searches are saved for quick access. Results update as you type. AI vision search - With AI models installed, search becomes much more powerful — describe what you\'re looking for in plain language like \"sunset at the beach\", \"birthday cake\", or \"dog playing in snow\". Gallery uses on-device CLIP models to understand image content, so your photos never leave your device. To enable this, go to Settings > Smart features and install the AI models. + AI vision search lets you describe what you\'re looking for in plain language like \"sunset at the beach\", \"birthday cake\", or \"dog playing in snow\". Gallery uses bundled on-device CLIP models to understand image content, so your photos never leave your device. After Gallery indexes your photos in the background, matching results appear from your local library. Classic navigation bar Switch between modern and classic navigation styles Navigation bar styles @@ -77,9 +77,9 @@ Go to Settings > Navigation Toggle \'Old navigation bar style\' Use the library - Quick access to favorites, trash, vault, and more + Quick access to favorites, trash, categories, and more The Library - The Library is your hub for everything beyond the main timeline and albums. From here you can access: Favorites (your starred photos), Trash (recently deleted items, recoverable for 30 days), Vault (encrypted private photos), Locations (photos organized by where they were taken), and Categories (AI-sorted photo groups). Think of it as your gallery\'s control center. + The Library is your hub for everything beyond the main timeline and albums. From here you can access: Favorites (your starred photos), Trash (recently deleted items, recoverable for 30 days), and Categories (AI-sorted photo groups). Think of it as your gallery\'s control center. Set your default launch screen Choose which screen appears when you open Gallery Default launch screen @@ -147,14 +147,6 @@ Go to Settings > Appearance Toggle \'Shared element transitions\' Tap any photo to see the effect - Change the app name - Display as \'Gallery\' or \'Refra\' on your home screen - App name - Choose whether the app shows as \'Gallery\' or \'Refra\' on your home screen and in the app drawer. \'Refra\' is the app\'s alternative name. This changes the icon label only — the app itself stays the same. You may need to restart your launcher or wait a moment for the change to appear on your home screen. - Go to Settings > General - Tap \'App name\' - Choose Gallery or Refra - The home screen icon label will update Group media by month @@ -292,16 +284,6 @@ Tap the edit icon in the bottom bar Choose the built-in editor or an external app Original is automatically backed up before editing - Hide media in vault - Move photos to encrypted vault storage - Hide in vault - Move a photo to the vault to encrypt it and hide it from your regular gallery. The original file is deleted from your device\'s storage and replaced with an encrypted copy inside the vault. Only you can see it — vault access requires biometric authentication (fingerprint or face) or your device PIN. - How to hide - You can hide individual photos from the viewer, or select multiple photos in the grid and hide them all at once through the selection action sheet. If you have multiple vaults, you\'ll be asked to choose which one to use. - Open a photo - Tap the three-dot menu - Select \'Hide in vault\' - Choose a vault if you have more than one Copy to album Copy media to another album Copy to album @@ -413,33 +395,23 @@ Drawing modes Choose between Pen (solid lines), Highlighter (semi-transparent, great for emphasizing areas), and Eraser (to fix mistakes). Pick any color using the HSV color picker, and adjust the brush size with the slider. The floating toolbar keeps tools accessible while drawing. How to draw - Zoom into the area you want to annotate for precision work. Drawing is fully reversible — use the eraser for small corrections or undo to remove the last stroke entirely. The original photo is always backed up before edits. + Zoom into the area you want to annotate for precision work. Drawing is fully reversible before saving — use the eraser for small corrections or undo to remove the last stroke entirely. Open a photo and tap edit Select the Markup adjustment Pinch to zoom into the area you want to draw on Choose your brush, color, and size Draw on the photo Use eraser or undo to correct mistakes - Save formats and history - Choose save format and view edit history - Save formats - When saving an edited photo, choose your preferred format. JPEG is the most compatible and produces smaller files. PNG is lossless (no quality loss) but creates larger files — great when quality is critical. WebP offers a good balance of quality and file size. You can also adjust the quality slider to control the file size vs. quality tradeoff. - Edit history - Gallery automatically backs up the original photo before any edit, so you can always restore it later. You can view all your edit backups in a dedicated screen showing the original and edited versions side by side. This safety net means you can experiment freely with edits. - Go to Settings > Smart features - Tap \'Edit backups viewer\' - View originals alongside edited versions - Restore any original if needed - Manage edit backups - View and restore previous versions of edited photos - Edit backups - Every time you edit a photo, Gallery saves the original version automatically. This means you can always go back to the untouched photo, even after multiple rounds of editing. Backups are stored in the app\'s internal storage and don\'t clutter your gallery. - Manage backups - The Edit Backups Viewer shows all your backed-up originals. You can compare the original with the edited version, restore an original to replace the edited photo, or delete old backups to free up storage space. If storage is a concern, periodically review and clean up backups you no longer need. - Go to Settings > Smart features - Tap \'Edit backups viewer\' - Browse your backup history - Restore originals or delete to free space + Save edited copies + Keep originals unchanged when saving edits + Save copy + The photo editor saves edits as a new image in the Edited folder. The original media item is not overwritten, so your timeline keeps both the untouched original and the edited result. + Before saving + Undo and redo are available while you are editing. Once you save a copy and leave the editor, the saved result is a separate media file and further changes should be made by opening that copy again. + Open a photo and tap edit + Apply lighting, color, crop, filter, or markup changes + Use undo or redo before saving if needed + Tap Save Copy to create the edited file Search by text @@ -453,13 +425,13 @@ Vision search Search using natural language descriptions like \"sunset at the beach\", \"birthday cake\", \"dog playing in snow\", or \"red car\". The AI understands image content, not just filenames, so you can find photos based on what\'s actually in them. All processing happens on your device — your photos are never sent to the cloud. How it works - Gallery uses CLIP (Contrastive Language-Image Pretraining) models that run entirely on your phone. When indexing is enabled, Gallery analyzes each photo in the background and creates a compact \'understanding\' of its content. When you search, your text query is matched against these understandings to find the most relevant photos. + Gallery uses CLIP (Contrastive Language-Image Pretraining) models that run entirely on your phone. During background indexing, Gallery analyzes each photo and creates a compact \'understanding\' of its content. When you search, your text query is matched against these understandings to find the most relevant photos. Get started - AI search requires ML models to be downloaded first (about 150 MB). Once installed, Gallery automatically indexes your photos in the background when the device is idle. The initial indexing may take some time depending on how many photos you have, but subsequent updates are fast. - Go to Settings > Smart features - Download the AI models (~150 MB) - Wait for background indexing to complete - Search with descriptions like \'sunset\' or \'birthday\' + AI search uses bundled ML models included with Gallery. Gallery indexes your photos in the background when the device is idle. The initial indexing may take some time depending on how many photos you have, but subsequent updates are fast. + Open Search + Let Gallery finish background indexing + Search with descriptions like \'sunset\' or \'birthday\' + Open a matching result from your library Search history Quickly access your recent searches Search history @@ -467,7 +439,7 @@ Background indexing How Gallery indexes your photos for fast search Search indexing - Gallery processes your photos in the background to enable fast AI-powered search. Indexing runs automatically when AI models are installed and the device is idle. The first index may take a while for large libraries (thousands of photos), but once complete, new photos are indexed quickly. You can check indexing progress from the timeline — a small status indicator shows when indexing is active. + Gallery processes your photos in the background to enable fast AI-powered search. Indexing runs automatically with the bundled AI models when the device is idle. The first index may take a while for large libraries (thousands of photos), but once complete, new photos are indexed quickly. You can check indexing progress from the timeline — a small status indicator shows when indexing is active. Auto-categorize photos @@ -493,20 +465,10 @@ Name your category Adjust the confidence threshold if needed The AI will start classifying photos automatically - Manage AI models - Download or manage on-device AI models - AI models - AI features (smart search and auto-categorization) require machine learning models that run entirely on your device — no internet connection needed after download, and your photos are never sent anywhere. The models are about 150 MB and are downloaded once. - Model management - Check whether models are installed, download them if needed, or remove them to free up space. After downloading, Gallery automatically starts indexing your photos in the background. You can monitor the model status at any time from this screen. - Go to Settings > Smart features - Check the AI models status - Tap \'Download\' if not yet installed - Wait for indexing to complete after download How indexing works Background processing for AI features AI indexing - Once AI models are installed, Gallery processes your photo library in the background to build an index that powers smart search and auto-categorization. This is a one-time process for existing photos, and new photos are indexed automatically as you take them. + Gallery uses bundled AI models to process your photo library in the background and build an index that powers smart search and auto-categorization. This is a one-time process for existing photos, and new photos are indexed automatically as you take them. Indexing details Indexing is designed to be unobtrusive — it runs when your device is idle to avoid impacting performance. A small progress indicator appears in the timeline during active indexing. For large libraries (10,000+ photos), the initial index may take several hours but only needs to happen once. @@ -566,38 +528,6 @@ Long press it Tap \'Set as album thumbnail\' - - Set up the vault - Encrypt and protect your private photos - Your private vault - The vault is a secure, encrypted space for photos and videos you want to keep private. Files in the vault are encrypted using strong cryptography and can only be accessed after biometric authentication (fingerprint or face) or your device PIN. They\'re completely hidden from your regular gallery and won\'t appear in the timeline, albums, or any other app. - Set up encryption - Setting up the vault is quick — just tap Library > Vault and follow the prompts. You can create multiple vaults for different purposes (e.g., \'Personal\' and \'Documents\'). Each vault requires authentication every time you access it. - Tap Library > Vault - Follow the setup prompts - Verify with your fingerprint, face, or PIN - You can create multiple vaults - Add media to vault - Move photos to the vault from the viewer (three-dot menu > Hide in vault) or by selecting multiple photos and using the selection action sheet. When you move a photo to the vault, the original is deleted from your gallery and replaced with an encrypted copy. Only vault access can reveal it. - Move photos to vault - Hide photos by moving them to encrypted storage - Hide photos - Moving a photo to the vault encrypts it and removes it from your visible gallery. The encrypted file is stored in the app\'s secure storage, inaccessible to other apps or file managers. This is the safest way to keep private photos on your device. - How to hide - From the viewer: tap the three-dot menu and select \'Hide in vault\'. From the grid: select one or more photos, then use the \'Hide in vault\' action in the selection sheet. You can hide photos, videos, or a mix of both. If you have multiple vaults, you\'ll be asked which one to use. - Open a photo, or select multiple in the grid - Tap the menu or selection action - Select \'Hide in vault\' - Choose a vault if you have more than one - Restore from vault - Move photos back to your regular gallery - Restore media - To bring photos back from the vault to your regular gallery, open the vault, select the photos you want to restore, and tap the restore action. The files are decrypted and placed back in their original location (or a default folder if the original no longer exists). Once restored, the photos reappear in your timeline and albums as if they were never hidden. - Streaming vault videos - Watch encrypted videos without full decryption - Streaming decryption - Videos stored in the vault play using streaming decryption, meaning they start playing almost immediately without needing to decrypt the entire file first. This is especially important for long or large video files that would otherwise take a long time to prepare. The decryption happens on-the-fly as the video plays, with no temporary unencrypted files created on disk. - Add to favorites Mark your best photos for quick access @@ -626,18 +556,6 @@ When off: deletions are immediate and permanent When on: deleted items go to trash for 30 days - - Browse by location - See your photos on a map - Location browsing - View your photos organized by the places where they were taken. Gallery reads GPS coordinates from your photo metadata and groups them by city, neighborhood, or landmark. This is a wonderful way to revisit trips, explore where you\'ve been, and discover photo clusters you might have forgotten about. - Location timeline - Tap any location to see all the photos taken there, displayed in a timeline view. If you have Mapbox maps enabled, you can also see an interactive map with pins showing exactly where each photo was captured. Note: location data depends on your camera saving GPS info — most phone cameras do this by default. - View location from photo - See where a photo was taken from the info sheet - Photo location - Swipe up on any photo in the viewer to reveal the info sheet. If the photo contains GPS metadata, you\'ll see the location displayed with an address. Tap it to open a map showing exactly where the photo was taken. You can also edit or delete the location data for privacy (see the Metadata section for details). - View photo metadata See camera, date, resolution, and location info @@ -716,7 +634,7 @@ Secure mode Hide from recents and block screenshots Secure mode - When activated, Gallery is hidden from the recent apps screen (the task switcher shows a blank preview) and screenshots within the app are blocked. This is an extra privacy measure if you don\'t want others to see what you were browsing when they glance at your recent apps. Ideal when combined with the vault for maximum privacy. + When activated, Gallery is hidden from the recent apps screen (the task switcher shows a blank preview) and screenshots within the app are blocked. This is an extra privacy measure if you don\'t want others to see what you were browsing when they glance at your recent apps. Go to Settings > General Toggle \'Secure mode\' Gallery disappears from recent apps @@ -733,7 +651,7 @@ Default launch screen Choose Auto, Timeline, Albums, or Library Launch screen - Choose which screen greets you when you open Gallery. Auto remembers the last screen you used. Timeline is great for browsing recent photos. Albums is ideal if you organize by folder. Library works well if you primarily access favorites, vault, or categories. + Choose which screen greets you when you open Gallery. Auto remembers the last screen you used. Timeline is great for browsing recent photos. Albums is ideal if you organize by folder. Library works well if you primarily access favorites, trash, or categories. Go to Settings > Navigation Tap \'Default launch screen\' Choose Auto, Timeline, Albums, or Library @@ -755,7 +673,7 @@ Customize selection actions Reorder and customize the selection action sheet Selection actions - The selection action sheet appears whenever you select photos. By default it shows Share, Delete, Copy, Move, Hide in vault, and more. You can customize which actions appear and their order to match your most common workflows. + The selection action sheet appears whenever you select photos. By default it shows Share, Delete, Copy, Move, Favorite, and more. You can customize which actions appear and their order to match your most common workflows. Customize Drag actions up or down to reorder them — put your most-used actions at the top for quick access. Toggle actions on or off to show or hide them. For example, if you never use \'Copy to album\', hide it to reduce clutter. Your customization is saved automatically. Go to Settings > Navigation @@ -763,26 +681,6 @@ Drag actions to reorder by priority Toggle actions on/off to show/hide - - AI models manager - Download and manage AI model status - AI models - View the current status of AI models: whether they\'re installed, downloading, or not yet set up. The models are required for AI-powered search and auto-categorization. They\'re downloaded once (~150 MB) and run entirely on your device. - Model management - Download models to enable AI features, check their status, or remove them to free storage. After downloading, Gallery automatically begins indexing your photos in the background. If models are already bundled with your app version, they\'ll be copied from assets on first launch. - Go to Settings > Smart features - Tap \'AI models manager\' - Download, verify, or remove models - Edit backups viewer - View and manage edit backup storage - Edit backups - Every time you edit a photo, Gallery saves the original version. This screen shows all your backups with their size and date. You can compare originals with edited versions side by side to see exactly what changed. - Manage storage - Backups can accumulate over time, especially if you edit many photos. Review your backups periodically and delete ones you no longer need to free storage. You can delete individual backups or clear them all at once. Restoring a backup replaces the edited photo with the original. - Go to Settings > Smart features - Tap \'Edit backups viewer\' - Browse, compare, restore, or delete backups - Pinch to zoom grid Change column count by pinching @@ -807,7 +705,7 @@ Selection action sheet Actions available for selected media Action sheet - When you select one or more photos, a bottom action sheet slides up showing all available actions: Share, Delete, Copy to album, Move to album, Hide in vault, Favorite, and more. The number of selected items is displayed at the top. Tap any action to apply it to all selected items at once. + When you select one or more photos, a bottom action sheet slides up showing all available actions: Share, Delete, Copy to album, Move to album, Favorite, and more. The number of selected items is displayed at the top. Tap any action to apply it to all selected items at once. Customize actions The action sheet is fully customizable. Reorder actions to put your most-used ones first, and hide actions you never use. This makes the sheet cleaner and faster to use. Your customization is saved permanently. Go to Settings > Navigation @@ -857,18 +755,6 @@ Open settings - - Cast videos to your TV - Stream media to FCast-compatible devices on your network - Cast to a device - Cast videos and images to any FCast-compatible device on your local network. Gallery discovers devices automatically using mDNS, sets up a local streaming server, and plays your media on the big screen — all without any cloud services. Encrypted vault media can also be cast securely via temporary decryption. - Control playback - While casting, a mini controller appears in the viewer showing cast status. Use the remote controls to play, pause, seek, adjust volume, and change playback speed — all from your phone. Tap the cast button again to stop casting or switch devices. - Open a video in the viewer - Tap the cast button in the top bar - Select a device from the picker - Use on-screen controls to manage playback - Auto-contrast (Beta) Automatically enhance display contrast based on image brightness @@ -887,107 +773,4 @@ Toggle \'Right-align top actions\' Select items to see the new layout - - Redesigned vault security - Gate authentication, custom passwords, and a streamlined vault flow - Gate authentication - Add an extra layer of security before the vault selector screen. Choose between no gate, device security (biometrics or screen lock), or a custom password. When active, you must authenticate before you can even see the list of available vaults — preventing others from knowing which vaults exist on your device. - Open the Vault from the Library - Tap the security settings button - Choose None, Device Security, or Custom Password - Custom passwords & per-vault security - Each vault now supports its own authentication — biometrics, PIN, or a custom password — so you can have different security levels for different vaults. The vault creation flow has been streamlined: create a vault, set its password, then configure the global gate. Vault deletion now returns you to the selector with re-authentication required, and the last vault deletion exits to the library. - Create or open a vault - Set a custom password or use device security - Each vault can have its own independent security - - - FCast video casting - Cast videos and images to devices on your local network - Auto-contrast (Beta) - Real-time luminance-based contrast enhancement in the viewer - Right-align selection actions - Move top action buttons to the right for easier one-handed use - Vault overhaul - Gate authentication, custom passwords, per-vault security, and streamlined flow - NoMaps WithML variant - New build variant with AI models but without map dependencies - Bug fixes & improvements - Fixed phantom selections, corrected setup permissions, and improved image loading - - - Album groups - Organize albums into collapsible groups - Merge albums by name - Automatically combine albums that share the same name - Merge sub-folder albums - Consolidate nested sub-folder albums into their parent - Frame-accurate video seeking - Precise video scrubbing with adjustable sensitivity - Image-to-image search - Find visually similar photos using any image as a query - Mosaic grid view - Dynamic mosaic tile layout for the timeline - Collections - Create custom albums by grouping photos from anywhere - Settings redesign - Complete UI and UX overhaul for all settings screens - Refreshed button style - New unified button design used throughout the app - Categories UI refresh - Redesigned categories screen with improved navigation - Configurable selection sheet - Customize which actions appear and their order - Granular grouping settings - Fine-tune which similar photos are grouped together - Optional AI models - AI models can now be downloaded on demand instead of bundled - Bug fixes - Fixed video codec exhaustion, favorite button in timeline, and grouped media selection - Full metadata viewer - View all EXIF and metadata for images and videos - Remade editor - Enhanced photo editor with text overlay support - MapLibre maps - Migrated from Mapbox to open-source MapLibre - Group similar media - Stack RAW+JPG, edited copies, and burst shots - Default editor - Choose built-in or external image editor - GIF animations - Option to disable GIF animation in grid view - Edit backups viewer - View and manage edit history backups - Copy to clipboard - Copy photos to clipboard for pasting - Enhanced markup - Zoom, pan, and floating drawing tools - Favorite icon position - Customize favorite icon placement on thumbnails - AI-powered search - Search photos using natural language descriptions - Smart categories - AI automatically organizes your photos - Color palette - Personalize app colors with wallpaper themes or curated presets - Panorama viewer - 360° panorama and photosphere viewing - Motion photos - Detect and play motion photo clips - Location browsing - Browse your photos on an interactive map by location - Custom album thumbnails - Set any photo as the cover for your albums - Redesigned selection sheet - Revamped selection actions with addons and select-all - Redesigned search - Rebuilt search experience with improved results and history - List-based albums - View your albums in a compact list layout - Lock images - Lock the current photo to prevent accidental swipes - Improved vault playback - Fixed encrypted video streaming for reliable playback - UI refresh - Polished interface with refined animations and layouts diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index e91647917d..c5d5899fdf 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #E5E5E8 + #FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea182b1856..9689fb14d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,5 @@ - - ReFra - Gallery - App name - Change the app name shown on your launcher - Choose app name - You may need to re-add the app to your home screen after changing the name + + Gallery Favorite icon on thumbnails Choose the position of the heart icon on media thumbnails Choose position @@ -71,9 +66,9 @@ Media Location @string/location_cd Location Map - by %s + Based on ReFra by %s IacobIonut01 - Donate + Donate to ReFra Github Donate Button Github Button @@ -104,7 +99,7 @@ Video Close Navigate up - Allow ReFra to Manage your Media + Allow Gallery to Manage your Media This setting just skips any extra confirmation dialogs while performing various actions like marking as favorite. Skip Allow @@ -150,7 +145,6 @@ Edit Metadata Moving to Trash Deleting Permanently - %1$s items Trashing %1$s items.. Deleting %1$s items... Long press to remove @@ -196,45 +190,31 @@ Album Name Create new album Copy + Some media couldn’t be copied + Check available storage and access. + Successful copies were kept. Check available storage and access. + Some items may already have been copied. Check the destination album before copying them again. + Couldn’t determine copy status + + %d item wasn’t copied + %d items weren’t copied + + + %d item copied successfully + %d items copied successfully + Copy to clipboard Copied to clipboard Failed to copy to clipboard Ignored Albums Select an album - Ignore albums from ReFra + Ignore albums from Gallery Add to ignored albums - Remove an album from showing inside the ReFra + Remove an album from showing inside the Gallery Remove from ignored list Remove from the ignored list the album named %1$s Override Save Copy - Revert to Original - This will discard all edits and restore the original image. This action cannot be undone. - Revert - Edited - Edit Backups - Manage original image backups from edited photos - Storage Used - %1$s used by %2$d backup(s) - No edit backups - Original images will be backed up when you override an edit - Auto-cleanup - Automatically delete backups older than 90 days - Deleted %1$d backup(s) older than 90 days - No backups older than 90 days - Delete All Backups - Permanently delete all original image backups - This will permanently delete all original image backups. You will no longer be able to revert any edited photos to their originals. - Delete Selected - Edited %1$s - Free Space - Backups - Actions - Used %1$s%% - %1$d selected - Select - Original - Edited Favorite Allow to Manage Media Set default launch screen @@ -253,24 +233,12 @@ Manage All Files "Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card)" Allow to Manage All Files - Vault Tools Library Biometric Authentication - Unlock your Vault - Add Media to Vault - Vault - Locked vault - Create new vault Toggle password visibility Delete last digit - Switch vault - Vault %1$s (%2$s) exists Granted - New Vault - Delete Vault - Unknown Vault - Hide Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. No ignored albums Setup ignored albums @@ -360,18 +328,10 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation - Setup your vault - - Vault Name - Please set-up a phone security measure before setting up the vault. - The vault is protected by your device lock screen (PIN, password, or biometrics).\n\nYour files are encrypted using AES-256 and can only be accessed after authenticating. - The vault is protected by your device lock screen (PIN, password, or biometrics).\n\nYour files are encrypted using AES-256 and can only be accessed after authenticating. - Your files are accessed on the fly and never stored decrypted on the device. - Vault \"%1$s\" already exists Ignored Location Update file name - Support the project + Support ReFra Click to copy Take Audio Focus Enabling this will take audio focus when playing media. Any media playing in background will pause while the video is playing. (Restart app required) @@ -397,47 +357,11 @@ I don\'t want this I don\'t want this, please remove it.\n(It can be enabled again in the settings) - - AI models are not installed. Download them from Settings > Smart Features. - AI Models Manager - Manage AI models for image search and classification - Download AI Models - Download AI models for image search and classification (~147 MB) - Downloading AI Models… - AI Models Installed - AI-powered search and classification are available - AI Model Error - Delete AI Models - Remove downloaded AI models to free up storage - Are you sure you want to delete the AI models? You will need to download them again to use AI features. - Model size: %s - Unavailable — Requires AI Models Feature - Status - Actions - Custom Models - AI models enable intelligent image search and automatic media classification. The app uses CLIP (Contrastive Language-Image Pretraining) models in ONNX format to understand the content of your photos. - You can replace the default models with custom ONNX CLIP models. Place the following files in your app\'s internal storage under models/clip/: - visual_quant.onnx — Vision encoder (processes images) - textual_quant.onnx — Text encoder (processes search queries) - vocab.json — Tokenizer vocabulary - merges.txt — Tokenizer merge rules - Models must be quantized CLIP models compatible with ONNX Runtime. The default models are downloaded from the Gallery repository. - Data Privacy - All AI processing is performed entirely on your device. No images, search queries, or classification data ever leave your phone. The models run locally using ONNX Runtime and do not require an internet connection once downloaded. Your media remains fully private. - Source - github.com/IacobIonut01/ReFra - Model Files - SHA-256 Verified - %s/s - %s remaining - Cancel Download - Features - Search by Description - Find photos by describing what\'s in them - Automatic Categories - Photos sorted into groups like Nature, Food, People - 100% On-Device - No cloud, no tracking — everything stays on your phone + + Preparing AI features… + AI model inference failed. + Could not load image. + Bundled AI models are unavailable in this build. Add category @@ -470,7 +394,6 @@ Create Save Browse categories - %1$d items Media (%1$d) No media in this category Select to remove @@ -510,7 +433,6 @@ %1$d selected Restore - Encrypted Date Format Date Formats Set your own custom date formats @@ -518,7 +440,6 @@ Categories %1$s classified media Scan for new categories - AI models are not downloaded. Download them from Settings → Smart Features. Scanning media... Date Header Media View @@ -543,94 +464,9 @@ Filters Markup Size - Encrypting media... - Encrypting media - Decrypting media - %1$d%% completed - Vault Operations - Progress of encryption / decryption tasks - Decrypt Vault - The current vault and its content will be decrypted and restored.\nThis action will make your content accessible again in the ReFra. - Confirm Decryption? Yes - Confirm Deletion? - The current vault and its content will be deleted permanently.\nThis action cannot be undone. - Create new vault? - A new vault will be created, protected by your device lock screen (PIN, password, or biometrics).\n\nYour files will be encrypted using AES-256 and can only be accessed after authenticating. - Select a vault - Choose which vault to open - %1$d item(s) - %1$d item(s) encrypted successfully - %1$d item(s) restored successfully - Item deleted from vault - %1$d item(s) added to vault - Add to vault - Delete originals after encrypting - Keep original files - Encrypt & Keep - Encrypt & Delete - Remember my choice - Vault encrypt behavior - Choose what happens to originals when adding media to a vault - Always ask - Always delete originals - Always keep originals - Use custom password - Set a separate password for this vault instead of using device security - Remove custom password - This will remove the custom password and revert to device security for unlocking this vault - Vault actions More options - Enter vault password - Confirm password - Passwords do not match - Password must be at least 4 characters - Incorrect password - Incorrect password (%1$d attempts left) - Too many attempts. Try again in %1$d seconds - Vault password set successfully - Vault password removed, using device security - Unlock vault - PIN - Pattern - Password - Choose lock type - Enter PIN - Confirm PIN - Draw unlock pattern - Confirm pattern - PIN must be at least 6 digits - Pattern must connect at least 4 dots - %1$d item(s) restored to %2$s - Permanently delete? - This item will be permanently deleted from the vault.\nThis action cannot be undone. - No vaults yet. Create one to hide your media. - Create Vault Verify your identity - Failed to encrypt %1$d item(s) - Hiding media… - Would you like to set a custom password for this vault? - Use device security - Set custom password - Delete \"%1$s\"? - Vault \"%1$s\" and all its content will be deleted permanently.\nThis action cannot be undone. - Your vaults are listed below. Authenticate to open one. - Manage security - Device security - Custom %1$s - Security settings for %1$s - Set custom password - Use a PIN, pattern, or password instead of device security - Remove custom password - Revert to device security (biometric or screen lock) - Vault selector security - Would you like to protect the vault selector screen? - This adds an extra layer of security before your vault list is visible. - None - Device security - Custom password - %1$d duplicate(s) already in vault, skipped - A file with the same content already exists at the destination. Skipped restore. Next Back Taken on @@ -669,6 +505,16 @@ Viewing experience, editor, video playback Navigation Launch screen, bars, interface behavior + Security + Sandboxing and isolated file processing + Metadata isolation mode + Controls how metadata parsing is isolated between files. All modes run in a separate process with no permissions; the difference is how often that process is recycled.\n\nShared process - All files are parsed through a single long-lived isolated process. Fastest option with minimal overhead for library scanning. If a malformed file crashes the parser, the process is restarted automatically.\n\nHybrid - Files you open in the viewer get a fresh isolated process; background indexing reuses a shared one. Actively viewed files are fully isolated from each other, while batch operations stay fast.\n\nPer-file - Every single file gets its own isolated process, destroyed immediately after parsing. Strongest isolation, but significantly slower for large libraries and uses more battery during scanning. + Shared process + Single reused isolated process for all files + Hybrid + Fresh process for opened files, shared for batch indexing + Per-file + Every file gets its own isolated process Groups all your photos and videos by month instead of by individual date. This gives a more compact overview when browsing large libraries, but you lose the per-day separation.\n\nChanging this setting will restart the app. @@ -707,7 +553,6 @@ Choose where the favorite heart icon appears on thumbnails in the grid, or hide it entirely. Choose which app opens when you tap the edit button. You can use the built-in editor or any compatible external photo editor installed on your device. Choose which screen appears when you open the app. You can pick a specific screen or let the app remember the last one you used. - Change the name and icon displayed on your home screen launcher. The app will restart to apply the change. Collecting metadata… @@ -744,10 +589,8 @@ Copy selected items to another album Move selected items to another album Move selected items to the trash - Encrypt and hide selected items in the vault Open the selected item in the photo editor Rotate selected items 90° clockwise - Add to Vault Extended Date Header Remove All Selfies @@ -758,23 +601,23 @@ Allows the app to display notifications Search Search media + Search albums Rotate + Couldn’t rotate this image. + This image is too large to rotate safely. + This image format can’t be rotated. Locked Lock album Unlock album Authenticate to view this album Please set up a phone security measure (PIN, pattern, password or biometrics) before locking or unlocking albums. - Locking an album only hides it behind authentication within this app. The contents will not be encrypted and will still be accessible by other apps and file managers.\n\nTo encrypt your files, use the Vault feature instead. - Decrypt failed. Tap to retry. + Locking an album only hides it behind authentication within this app. The contents will not be encrypted and will still be accessible by other apps and file managers. Other Require confirmation on trash Enable to require an extra confirmation before moving your media to the trash Smart Features]]> Categories Settings Manage scanning, categories and classification - %1$d locations - No locations found - Locations No categories found @@ -816,10 +659,6 @@ More Framing Done - Save as copy - Save - You can always undo these changes - Your original won\'t change Compare with original Mirror image Rotate 90 degrees @@ -930,7 +769,6 @@ Create a collection to organize your photos and videos without moving them. This collection is empty Add photos and videos to this collection. - %1$d items Select collection Create & add Added to %1$s @@ -945,17 +783,10 @@ Help & tips - Tutorials, tips, and what\'s new - What\'s new - Discover the latest Gallery features - What\'s new in v%s - %d new features to explore - Explore what\'s new - Featured + Tutorials and tips Get started Make the most of Gallery Explore more tips - Previous releases Done Try it now Open settings @@ -965,7 +796,6 @@ %d page %d pages - Latest This tip could not be found. The basics Navigation @@ -978,50 +808,22 @@ Search AI features Albums & collections - Vault Favorites & trash - Location browsing Metadata & EXIF Settings: Appearance Settings: General Settings: Navigation - Settings: Smart features Gestures & shortcuts Selection & batch actions Accessibility & advanced - - Cancel - Cast - Disconnect - Select a device - Connected to %1$s - You are currently connected to this device. Tap disconnect to stop casting. - Searching for devices… - Casting to %1$s - Volume up - Volume down - Seek forward 10s - Seek backward 10s - Stop casting - Cast to device - Connect manually - Enter IP address - 192.168.1.100 - Port (default: 46899) - Connect - Cast this media - Stop current cast - Now casting - Permissions required - The following permissions are needed for casting. Please grant them in app settings. - Open settings - Internet - Required for connecting to cast devices and streaming media - Wi-Fi state - Required for detecting network information - Network state - Required for checking network connectivity - Multicast - Required for automatic device discovery on your network + Couldn’t load this image + Retry + AI media analysis + Off by default. Enable local semantic search and media categories. Your library will be analysed only on this device. + Enabled. New and changed media will be analysed locally on this device. + Analysing your media… + Analysing your media… %d%% + Applying changes… + Couldn’t save this widget. Try again. diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml deleted file mode 100644 index fa0f996d2c..0000000000 --- a/app/src/main/res/xml/backup_rules.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml deleted file mode 100644 index 9ee9997b0b..0000000000 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml index 1576d7d0c4..d5f3f5df87 100644 --- a/app/src/main/res/xml/filepaths.xml +++ b/app/src/main/res/xml/filepaths.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryCameraPosition.kt b/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryCameraPosition.kt deleted file mode 100644 index 1d0f042a52..0000000000 --- a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryCameraPosition.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng - -/** - * Lightweight camera data class for Compose state. - */ -data class GalleryCameraPosition( - val latitude: Double = 0.0, - val longitude: Double = 0.0, - val zoom: Double = 2.0, - val tilt: Double = 0.0, - val bearing: Double = 0.0, - val paddingLeft: Double = 0.0, - val paddingTop: Double = 0.0, - val paddingRight: Double = 0.0, - val paddingBottom: Double = 0.0, -) - -fun GalleryCameraPosition.toNative(): CameraPosition { - return CameraPosition.Builder() - .target(LatLng(latitude, longitude)) - .zoom(zoom) - .tilt(tilt) - .bearing(bearing) - .padding(paddingLeft, paddingTop, paddingRight, paddingBottom) - .build() -} diff --git a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapState.kt b/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapState.kt deleted file mode 100644 index b4e534c702..0000000000 --- a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapState.kt +++ /dev/null @@ -1,315 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import android.graphics.Bitmap -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView -import org.maplibre.android.maps.Style -import org.maplibre.android.style.expressions.Expression -import org.maplibre.android.style.layers.CircleLayer -import org.maplibre.android.style.layers.HeatmapLayer -import org.maplibre.android.style.layers.PropertyFactory -import org.maplibre.android.style.layers.SymbolLayer -import org.maplibre.android.style.sources.GeoJsonOptions -import org.maplibre.android.style.sources.GeoJsonSource - -@Stable -class GalleryMapState( - initialPosition: GalleryCameraPosition, -) { - // ── Compose-observable state ── - var isStyleLoaded by mutableStateOf(false) - internal set - - var cameraPosition by mutableStateOf(initialPosition) - - // ── Internal refs ── - var mapView: MapView? by mutableStateOf(null) - internal set - @PublishedApi internal var map: MapLibreMap? = null - @PublishedApi internal var style: Style? = null - - // ── Public API ── - - /** - * Execute [block] only when the style is fully loaded. - * Safe to call at any time; silently no-ops if the style isn't ready. - */ - inline fun withStyle(block: Style.(MapLibreMap) -> Unit) { - val s = style ?: return - val m = map ?: return - block(s, m) - } - - /** - * Move camera instantly (no animation). - */ - fun moveCamera(position: GalleryCameraPosition) { - cameraPosition = position - map?.moveCamera( - CameraUpdateFactory.newCameraPosition(position.toNative()) - ) - } - - /** - * Animate camera with a given duration. - */ - fun animateCamera(position: GalleryCameraPosition, durationMs: Int = 500) { - cameraPosition = position - map?.animateCamera( - CameraUpdateFactory.newCameraPosition(position.toNative()), - durationMs - ) - } - - /** - * Set camera padding (e.g. for bottom sheet). - * Uses CameraPosition.padding (v13 API) instead of the deprecated setPadding. - */ - fun setCameraPadding( - left: Double = 0.0, - top: Double = 0.0, - right: Double = 0.0, - bottom: Double = 0.0, - ) { - val m = map ?: return - val cur = m.cameraPosition - val newPos = CameraPosition.Builder(cur) - .padding(left, top, right, bottom) - .build() - m.moveCamera(CameraUpdateFactory.newCameraPosition(newPos)) - } - - /** - * Add or update a GeoJSON source. If the source already exists, its data is updated. - */ - fun setGeoJsonSource( - id: String, - geoJson: String, - cluster: Boolean = false, - clusterRadius: Int = 50, - clusterMaxZoom: Int = 15, - ) { - val s = style ?: return - val existing = s.getSourceAs(id) - if (existing != null) { - existing.setGeoJson(geoJson) - } else { - val options = if (cluster) { - GeoJsonOptions() - .withCluster(true) - .withClusterRadius(clusterRadius) - .withClusterMaxZoom(clusterMaxZoom) - } else { - null - } - val source = if (options != null) { - GeoJsonSource(id, geoJson, options) - } else { - GeoJsonSource(id, geoJson) - } - s.addSource(source) - } - } - - /** - * Remove a source by id (no-op if it doesn't exist). - */ - fun removeSource(id: String) { - style?.removeSource(id) - } - - /** - * Add a HeatmapLayer if it doesn't already exist. - */ - fun addHeatmapLayer( - id: String, - sourceId: String, - weight: Expression? = null, - intensity: Expression? = null, - color: Expression? = null, - radius: Expression? = null, - opacity: Expression? = null, - belowLayerId: String? = null, - ) { - val s = style ?: return - if (s.getLayer(id) != null) return - val layer = HeatmapLayer(id, sourceId) - val props = buildList { - weight?.let { add(PropertyFactory.heatmapWeight(it)) } - intensity?.let { add(PropertyFactory.heatmapIntensity(it)) } - color?.let { add(PropertyFactory.heatmapColor(it)) } - radius?.let { add(PropertyFactory.heatmapRadius(it)) } - opacity?.let { add(PropertyFactory.heatmapOpacity(it)) } - } - if (props.isNotEmpty()) layer.setProperties(*props.toTypedArray()) - if (belowLayerId != null && s.getLayer(belowLayerId) != null) { - s.addLayerBelow(layer, belowLayerId) - } else { - s.addLayer(layer) - } - } - - /** - * Add a CircleLayer if it doesn't already exist, or update its visibility. - */ - fun addOrUpdateCircleLayer( - id: String, - sourceId: String, - visible: Boolean = true, - radius: Float? = null, - color: String? = null, - strokeWidth: Float? = null, - strokeColor: String? = null, - aboveLayerId: String? = null, - ) { - val s = style ?: return - val existing = s.getLayerAs(id) - if (existing != null) { - existing.setProperties( - PropertyFactory.visibility(if (visible) "visible" else "none") - ) - return - } - val layer = CircleLayer(id, sourceId) - val props = buildList { - add(PropertyFactory.visibility(if (visible) "visible" else "none")) - radius?.let { add(PropertyFactory.circleRadius(it)) } - color?.let { add(PropertyFactory.circleColor(it)) } - strokeWidth?.let { add(PropertyFactory.circleStrokeWidth(it)) } - strokeColor?.let { add(PropertyFactory.circleStrokeColor(it)) } - } - layer.setProperties(*props.toTypedArray()) - if (aboveLayerId != null && s.getLayer(aboveLayerId) != null) { - s.addLayerAbove(layer, aboveLayerId) - } else { - s.addLayer(layer) - } - } - - /** - * Add a SymbolLayer if it doesn't already exist, or update its icon + visibility. - */ - fun addOrUpdateSymbolLayer( - id: String, - sourceId: String, - iconImageName: String? = null, - visible: Boolean = true, - iconSize: Float = 1f, - iconAllowOverlap: Boolean = true, - iconIgnorePlacement: Boolean = true, - aboveLayerId: String? = null, - ) { - val s = style ?: return - val existing = s.getLayerAs(id) - if (existing != null) { - val props = buildList { - add(PropertyFactory.visibility(if (visible) "visible" else "none")) - iconImageName?.let { add(PropertyFactory.iconImage(it)) } - } - existing.setProperties(*props.toTypedArray()) - return - } - val layer = SymbolLayer(id, sourceId) - val props = buildList { - add(PropertyFactory.visibility(if (visible) "visible" else "none")) - iconImageName?.let { add(PropertyFactory.iconImage(it)) } - add(PropertyFactory.iconSize(iconSize)) - add(PropertyFactory.iconAllowOverlap(iconAllowOverlap)) - add(PropertyFactory.iconIgnorePlacement(iconIgnorePlacement)) - } - layer.setProperties(*props.toTypedArray()) - if (aboveLayerId != null && s.getLayer(aboveLayerId) != null) { - s.addLayerAbove(layer, aboveLayerId) - } else { - s.addLayer(layer) - } - } - - /** - * Add or replace a bitmap image in the style. - */ - fun setImage(name: String, bitmap: Bitmap) { - val s = style ?: return - s.addImage(name, bitmap) - } - - /** - * Remove a bitmap image from the style. - */ - fun removeImage(name: String) { - style?.removeImage(name) - } - - /** - * Remove a layer by id (no-op if it doesn't exist). - */ - fun removeLayer(id: String) { - style?.removeLayer(id) - } - - // ── Internal ── - - internal fun onStyleLoaded(loadedStyle: Style, mapLibreMap: MapLibreMap) { - style = loadedStyle - map = mapLibreMap - stripHeavyLayers(loadedStyle) - isStyleLoaded = true - } - - /** - * Remove layers that are unnecessary for a photo-gallery map. - * This reduces rendering cost and speeds up tile display. - */ - private fun stripHeavyLayers(s: Style) { - val removePrefixes = listOf( - "building-3d", // fill-extrusion is GPU-heavy - "poi_", // points of interest — not needed - "aeroway_", // airports / runways - "airport", // airport labels - "tunnel_", // detailed tunnel rendering - "road_one_way", // one-way arrows - "highway-shield", // road shields - "road_shield", // road shields (US) - "natural_earth", // raster satellite overlay at low zoom - ) - val removeExact = setOf( - "road_area_pattern", // pedestrian area patterns - "landcover_wetland", // pattern-fill layer - ) - for (layer in s.layers.toList()) { - val id = layer.id - if (id in removeExact || removePrefixes.any { id.startsWith(it) }) { - s.removeLayer(id) - } - } - // Also remove the raster source itself to stop tile fetches - try { s.removeSource("ne2_shaded") } catch (_: Exception) {} - } - - internal fun onDestroy() { - isStyleLoaded = false - style = null - map = null - mapView = null - } -} - -@Composable -fun rememberGalleryMapState( - initialPosition: GalleryCameraPosition = GalleryCameraPosition(), -): GalleryMapState { - return remember { GalleryMapState(initialPosition) } -} diff --git a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapView.kt b/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapView.kt deleted file mode 100644 index 5d714c35d9..0000000000 --- a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/GalleryMapView.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import org.maplibre.android.MapLibre -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView - -@Composable -fun GalleryMapView( - modifier: Modifier = Modifier, - mapState: GalleryMapState, - styleUri: String, - isLogoEnabled: Boolean = true, - isAttributionEnabled: Boolean = false, - isCompassEnabled: Boolean = false, - isScaleBarEnabled: Boolean = false, - onMapClick: ((LatLng) -> Boolean)? = null, - onCameraMoved: ((GalleryCameraPosition) -> Unit)? = null, - onUserInteraction: (() -> Unit)? = null, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - - // Track the current styleUri to detect changes - var currentStyleUri by remember { mutableStateOf(styleUri) } - - // Remember the MapView — call onCreate immediately so the native - // renderer initialises without waiting for a lifecycle event. - val mapView = remember { - MapLibre.getInstance(context) - MapView(context).also { mv -> - mv.onCreate(null) - mapState.mapView = mv - } - } - - // Setup map async once + react to style URI changes - DisposableEffect(mapView, styleUri) { - currentStyleUri = styleUri - mapView.getMapAsync { mapLibreMap -> - // Ornament settings - mapLibreMap.uiSettings.apply { - this.isLogoEnabled = isLogoEnabled - this.isAttributionEnabled = isAttributionEnabled - this.isCompassEnabled = isCompassEnabled - } - - // Set initial camera - mapLibreMap.cameraPosition = mapState.cameraPosition.toNative() - - // Load style - mapLibreMap.setStyle(styleUri) { loadedStyle -> - mapState.onStyleLoaded(loadedStyle, mapLibreMap) - } - - // Camera listener — sync back to Compose state - mapLibreMap.addOnCameraMoveListener { - val pos = mapLibreMap.cameraPosition - val galleryPos = GalleryCameraPosition( - latitude = pos.target?.latitude ?: 0.0, - longitude = pos.target?.longitude ?: 0.0, - zoom = pos.zoom, - tilt = pos.tilt, - bearing = pos.bearing, - ) - mapState.cameraPosition = galleryPos - onCameraMoved?.invoke(galleryPos) - } - - // Detect user-initiated gestures (pan, zoom, rotate) - if (onUserInteraction != null) { - mapLibreMap.addOnCameraMoveStartedListener { reason -> - if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) { - onUserInteraction() - } - } - } - - // Click listener - if (onMapClick != null) { - mapLibreMap.addOnMapClickListener { latLng -> - onMapClick(latLng) - } - } - - mapState.map = mapLibreMap - } - - onDispose { - // Style changed or composable left — reset style state - mapState.isStyleLoaded = false - mapState.style = null - } - } - - // Lifecycle management — catch up with current state then observe future events. - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> mapView.onStart() - Lifecycle.Event.ON_RESUME -> mapView.onResume() - Lifecycle.Event.ON_PAUSE -> mapView.onPause() - Lifecycle.Event.ON_STOP -> mapView.onStop() - Lifecycle.Event.ON_DESTROY -> { - mapState.onDestroy() - mapView.onDestroy() - } - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - - // The composable likely enters when the lifecycle is already RESUMED, - // so the observer above would never get ON_START / ON_RESUME. - // Catch up manually so the render thread starts immediately. - val currentState = lifecycleOwner.lifecycle.currentState - if (currentState.isAtLeast(Lifecycle.State.STARTED)) mapView.onStart() - if (currentState.isAtLeast(Lifecycle.State.RESUMED)) mapView.onResume() - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - AndroidView( - modifier = modifier, - factory = { mapView }, - ) -} diff --git a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt b/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt deleted file mode 100644 index 1de49d0703..0000000000 --- a/app/src/maps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt +++ /dev/null @@ -1,719 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.graphics.createBitmap -import androidx.window.core.layout.WindowSizeClass -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.dot.gallery.R -import com.dot.gallery.core.LocalEventHandler -import com.dot.gallery.core.Settings.Misc.rememberAllowBlur -import com.dot.gallery.core.navigate -import com.dot.gallery.core.presentation.components.NavigationBackButton -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.MediaMetadataState -import com.dot.gallery.feature_node.domain.util.getUri -import com.dot.gallery.feature_node.presentation.util.GlideInvalidation -import com.dot.gallery.feature_node.presentation.util.LocalHazeState -import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.feature_node.presentation.util.getDate -import com.dot.gallery.feature_node.presentation.util.rememberSurfaceCapture -import com.dot.gallery.ui.theme.isDarkTheme -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.add -import kotlinx.serialization.json.addJsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject -import org.maplibre.android.style.expressions.Expression -import androidx.compose.ui.graphics.Color as ComposeColor - -private const val OPEN_FREE_MAP_LIGHT = "https://tiles.openfreemap.org/styles/liberty" -private const val OPEN_FREE_MAP_DARK = "https://tiles.openfreemap.org/styles/dark" - -@Suppress("ComposeRules", "UNUSED_PARAMETER") -@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) -@Composable -internal fun MapLocationsContent( - metadataState: State, - locations: List = emptyList(), - geoMedia: List = emptyList(), - initialMediaId: Long = -1L, -) { - val sheetHazeState = LocalHazeState.current - val allowBlur by rememberAllowBlur() - val context = LocalContext.current - val eventHandler = LocalEventHandler.current - val scope = rememberCoroutineScope() - val isDark = isDarkTheme() - - // Sort + build grid items off the main thread - var sortedGeoMedia by remember { mutableStateOf(emptyList()) } - var gridItems by remember { mutableStateOf(emptyList()) } - LaunchedEffect(geoMedia) { - if (geoMedia.isEmpty()) { - sortedGeoMedia = emptyList() - gridItems = emptyList() - return@LaunchedEffect - } - withContext(Dispatchers.Default) { - val sorted = geoMedia.sortedByDescending { it.media.definedTimestamp } - val items = buildList { - var lastDateGroup = "" - for (item in sorted) { - val dateGroup = item.media.definedTimestamp.getDate( - "EEE, d MMM", - "EEEE", - "EEE, d MMM yyyy", - "Today", - "Yesterday" - ) - if (dateGroup != lastDateGroup) { - add(MapGridItem.Header(dateGroup)) - lastDateGroup = dateGroup - } - add(MapGridItem.MediaCell(item)) - } - } - sortedGeoMedia = sorted - gridItems = items - } - } - - // Saveable state for configuration changes - var savedLat by rememberSaveable { mutableDoubleStateOf(30.0) } - var savedLng by rememberSaveable { mutableDoubleStateOf(10.0) } - var savedZoom by rememberSaveable { mutableDoubleStateOf(12.0) } - var selectedMediaId by rememberSaveable { mutableLongStateOf(-1L) } - - val selectedGeoMedia = remember(selectedMediaId, sortedGeoMedia) { - if (selectedMediaId != -1L) sortedGeoMedia.firstOrNull { it.mediaId == selectedMediaId } - else null - } - - // Whether to show the selected-media marker on the map. - // Hidden when the user manually zooms/pans; shown again when the timeline scrolls. - var showSelectedMarker by remember { mutableStateOf(true) } - - val gridState = rememberLazyGridState() - - // Adaptive layout detection - val windowAdaptiveInfo = currentWindowAdaptiveInfo() - val useWideLayout = - windowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) - - val sheetPeekHeight = 280.dp - val sheetPeekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() } - var currentSheetPaddingPx by remember { mutableFloatStateOf(if (useWideLayout) 0f else sheetPeekHeightPx) } - val density = LocalDensity.current - - val mapState = rememberGalleryMapState( - initialPosition = GalleryCameraPosition( - latitude = savedLat, - longitude = savedLng, - zoom = savedZoom, - ) - ) - - val accentColor = MaterialTheme.colorScheme.primary - val surfaceColor = MaterialTheme.colorScheme.surface - - val accentArgb = remember(accentColor) { - Color.argb( - (accentColor.alpha * 255).toInt(), - (accentColor.red * 255).toInt(), - (accentColor.green * 255).toInt(), - (accentColor.blue * 255).toInt() - ) - } - val surfaceArgb = remember(surfaceColor) { - Color.argb( - (surfaceColor.alpha * 255).toInt(), - (surfaceColor.red * 255).toInt(), - (surfaceColor.green * 255).toInt(), - (surfaceColor.blue * 255).toInt() - ) - } - - val accentComposeColor = ComposeColor(accentArgb) - val surfaceComposeColor = ComposeColor(surfaceArgb) - - // ── Load circular thumbnail for selected media marker ── - var selectedThumbBitmap by remember { mutableStateOf(null) } - LaunchedEffect(selectedGeoMedia) { - val item = selectedGeoMedia ?: run { - selectedThumbBitmap = null - return@LaunchedEffect - } - selectedThumbBitmap = null - withContext(Dispatchers.IO) { - runCatching { - val thumbSize = 192 - val uri = item.media.getUri() - val bitmap = Glide.with(context.applicationContext) - .asBitmap() - .load(uri) - .centerCrop() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .signature(GlideInvalidation.signature(item.media)) - .submit(thumbSize, thumbSize) - .get() - - val output = createBitmap(thumbSize, thumbSize) - val canvas = Canvas(output) - val half = thumbSize / 2f - - // Opaque background circle so the thumbnail is never transparent - val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = surfaceArgb - style = Paint.Style.FILL - } - canvas.drawCircle(half, half, half, bgPaint) - - // Clip the photo into a circle - val photoPaint = Paint(Paint.ANTI_ALIAS_FLAG) - canvas.saveLayer(null, null) - canvas.drawCircle(half, half, half, photoPaint) - photoPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) - canvas.drawBitmap( - bitmap, - (thumbSize - bitmap.width) / 2f, - (thumbSize - bitmap.height) / 2f, - photoPaint - ) - canvas.restore() - - // Border ring - val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = 6f - color = surfaceArgb - } - canvas.drawCircle(half, half, half - 3f, borderPaint) - - selectedThumbBitmap = output.asImageBitmap() - } - } - } - - // ── Capture map SurfaceView for haze blur ── - val mapCaptureState = rememberSurfaceCapture( - view = mapState.mapView, - enabled = allowBlur && mapState.isStyleLoaded, - intervalMs = 50L - ) - - // Set initial selection when data loads — also set the camera position directly - // (no animation) so the map opens already centred on the selected media. - var hasSetInitialPosition by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(sortedGeoMedia) { - if (sortedGeoMedia.isEmpty() || hasSetInitialPosition) return@LaunchedEffect - val first = sortedGeoMedia.first() - selectedMediaId = first.mediaId - hasSetInitialPosition = true - mapState.moveCamera( - GalleryCameraPosition( - latitude = first.latitude, - longitude = first.longitude, - zoom = 12.0, - paddingBottom = currentSheetPaddingPx.toDouble(), - ) - ) - } - - // When opened with a specific mediaId (e.g. from a city timeline), - // scroll the grid to that media once items are ready. This triggers - // the existing scroll→select→camera animation flow. - var hasScrolledToInitial by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(initialMediaId, gridItems) { - if (initialMediaId == -1L || gridItems.isEmpty() || hasScrolledToInitial) return@LaunchedEffect - val targetIndex = gridItems.indexOfFirst { - it is MapGridItem.MediaCell && it.geoMedia.mediaId == initialMediaId - } - if (targetIndex >= 0) { - hasScrolledToInitial = true - gridState.scrollToItem(targetIndex) - } - } - - // Track first visible grid item → update selected media (only when user scrolls the grid) - LaunchedEffect(gridState, gridItems) { - snapshotFlow { gridState.firstVisibleItemIndex } - .collect { index -> - val mediaItem = (index until gridItems.size) - .firstNotNullOfOrNull { i -> - (gridItems.getOrNull(i) as? MapGridItem.MediaCell)?.geoMedia - } - if (mediaItem != null && mediaItem.mediaId != selectedMediaId) { - selectedMediaId = mediaItem.mediaId - showSelectedMarker = true - } - } - } - - // Fly camera when selected location changes - val selectedLocationKey = remember(selectedGeoMedia) { - selectedGeoMedia?.let { "${it.latitude},${it.longitude}" } ?: "" - } - var skipInitialAnimation by rememberSaveable { mutableStateOf(initialMediaId == -1L) } - LaunchedEffect(selectedLocationKey, mapState.isStyleLoaded) { - if (!mapState.isStyleLoaded) return@LaunchedEffect - val item = selectedGeoMedia ?: return@LaunchedEffect - // Skip the first fire — initial position was already set directly above - if (skipInitialAnimation) { - skipInitialAnimation = false - return@LaunchedEffect - } - mapState.animateCamera( - GalleryCameraPosition( - latitude = item.latitude, - longitude = item.longitude, - zoom = 12.0, - paddingBottom = currentSheetPaddingPx.toDouble(), - ), - durationMs = 500 - ) - } - - // Save camera position for config changes (debounced to avoid recomposition storm) - LaunchedEffect(mapState) { - snapshotFlow { mapState.cameraPosition } - .debounce(500) - .collect { pos -> - savedLat = pos.latitude - savedLng = pos.longitude - savedZoom = pos.zoom - } - } - - // ── Build GeoJSON sources off the main thread ── - var heatmapGeoJson by remember { mutableStateOf(null) } - LaunchedEffect(geoMedia) { - if (geoMedia.isEmpty()) { - heatmapGeoJson = null - return@LaunchedEffect - } - heatmapGeoJson = withContext(Dispatchers.Default) { - buildJsonObject { - put("type", "FeatureCollection") - putJsonArray("features") { - geoMedia.forEach { item -> - addJsonObject { - put("type", "Feature") - putJsonObject("geometry") { - put("type", "Point") - putJsonArray("coordinates") { - add(item.longitude) - add(item.latitude) - } - } - putJsonObject("properties") {} - } - } - } - }.toString() - } - } - - val selectedGeoJson = remember(selectedGeoMedia) { - val item = selectedGeoMedia - ?: return@remember "{\"type\":\"FeatureCollection\",\"features\":[]}" - buildJsonObject { - put("type", "FeatureCollection") - putJsonArray("features") { - addJsonObject { - put("type", "Feature") - putJsonObject("geometry") { - put("type", "Point") - putJsonArray("coordinates") { - add(item.longitude) - add(item.latitude) - } - } - putJsonObject("properties") {} - } - } - }.toString() - } - - // Helper: navigate to media viewer for a given media - fun openMediaViewer(geoMedia: GeoMedia) { - val city = geoMedia.locationCity - val country = geoMedia.locationCountry - if (!city.isNullOrEmpty() && !country.isNullOrEmpty()) { - eventHandler.navigate( - Screen.LocationTimelineScreen.location(city, country) - ) - eventHandler.navigate( - Screen.MediaViewScreen.idAndLocation( - geoMedia.mediaId, - city, - country - ) - ) - } - } - - // ── Imperative layer/source management ── - // All map mutations happen in LaunchedEffects that check isStyleLoaded. - // Sources are added BEFORE layers — no composition lifecycle race. - - val accentHex = remember(accentArgb) { String.format("#%08X", accentArgb) } - val surfaceHex = remember(surfaceArgb) { String.format("#%08X", surfaceArgb) } - - // Heatmap source + layer (Google Photos-style: purple → magenta → orange → yellow) - LaunchedEffect(mapState.isStyleLoaded, heatmapGeoJson) { - if (!mapState.isStyleLoaded) return@LaunchedEffect - val json = heatmapGeoJson - if (json != null) { - mapState.setGeoJsonSource( - id = "heatmap-source", - geoJson = json, - ) - mapState.addHeatmapLayer( - id = "media-heatmap", - sourceId = "heatmap-source", - belowLayerId = "media-selected-circle", - weight = Expression.literal(1.0f), - intensity = Expression.interpolate( - Expression.linear(), Expression.zoom(), - Expression.stop(0, 0.15f), - Expression.stop(5, 0.3f), - Expression.stop(10, 0.5f), - Expression.stop(15, 0.7f), - Expression.stop(20, 1.0f) - ), - color = Expression.interpolate( - Expression.linear(), Expression.heatmapDensity(), - Expression.stop(0.0, Expression.rgba(0, 0, 0, 0)), - Expression.stop(0.1, Expression.rgba(75, 20, 150, 0.35f)), - Expression.stop(0.25, Expression.rgba(110, 40, 190, 0.55f)), - Expression.stop(0.4, Expression.rgba(170, 30, 170, 0.65f)), - Expression.stop(0.55, Expression.rgba(210, 50, 110, 0.7f)), - Expression.stop(0.7, Expression.rgba(235, 100, 50, 0.75f)), - Expression.stop(0.85, Expression.rgba(245, 160, 30, 0.8f)), - Expression.stop(1.0, Expression.rgba(255, 220, 60, 0.85f)) - ), - radius = Expression.interpolate( - Expression.linear(), Expression.zoom(), - Expression.stop(0, 6f), - Expression.stop(5, 15f), - Expression.stop(10, 25f), - Expression.stop(15, 35f), - Expression.stop(20, 45f) - ), - opacity = Expression.interpolate( - Expression.linear(), Expression.zoom(), - Expression.stop(0, 0.8f), - Expression.stop(10, 0.75f), - Expression.stop(18, 0.6f) - ) - ) - } - } - - // Selected point source + layers (circle fallback + thumbnail icon) - LaunchedEffect(mapState.isStyleLoaded, selectedGeoJson, selectedThumbBitmap, accentHex, surfaceHex, showSelectedMarker) { - if (!mapState.isStyleLoaded) return@LaunchedEffect - mapState.setGeoJsonSource(id = "selected-source", geoJson = selectedGeoJson) - val hasThumb = selectedThumbBitmap != null - mapState.addOrUpdateCircleLayer( - id = "media-selected-circle", - sourceId = "selected-source", - visible = showSelectedMarker && !hasThumb, - radius = 14f, - color = accentHex, - strokeWidth = 3f, - strokeColor = surfaceHex, - aboveLayerId = "media-heatmap" - ) - val thumbBmp = selectedThumbBitmap - if (thumbBmp != null) { - val androidBitmap = createBitmap(thumbBmp.width, thumbBmp.height) - val buffer = IntArray(thumbBmp.width * thumbBmp.height) - thumbBmp.readPixels(buffer) - androidBitmap.setPixels(buffer, 0, thumbBmp.width, 0, 0, thumbBmp.width, thumbBmp.height) - mapState.setImage("selected-thumb", androidBitmap) - } - mapState.addOrUpdateSymbolLayer( - id = "media-selected-thumb", - sourceId = "selected-source", - iconImageName = if (hasThumb) "selected-thumb" else null, - visible = showSelectedMarker && hasThumb, - iconSize = 1.5f, - aboveLayerId = "media-selected-circle" - ) - } - - // ── Shared composable: Map ── - val styleUri = remember(isDark) { - if (isDark) OPEN_FREE_MAP_DARK else OPEN_FREE_MAP_LIGHT - } - - val mapContent: @Composable (Modifier) -> Unit = { modifier -> - Box(modifier = modifier) { - MapBlurOverlay(mapCaptureState, sheetHazeState) - - GalleryMapView( - modifier = Modifier.fillMaxSize(), - mapState = mapState, - styleUri = styleUri, - onUserInteraction = { - // User is manually interacting with the map — hide the marker - showSelectedMarker = false - }, - onMapClick = { latLng -> - val closest = sortedGeoMedia.minByOrNull { geo -> - val dx = geo.longitude - latLng.longitude - val dy = geo.latitude - latLng.latitude - dx * dx + dy * dy - } - if (closest != null) { - selectedMediaId = closest.mediaId - showSelectedMarker = true - val index = gridItems.indexOfFirst { - it is MapGridItem.MediaCell && it.geoMedia.mediaId == closest.mediaId - } - if (index >= 0) { - scope.launch { gridState.animateScrollToItem(index) } - } - } - true - } - ) - - // Back button - @OptIn(ExperimentalHazeMaterialsApi::class) - NavigationBackButton( - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(8.dp), - containerColor = if (allowBlur) ComposeColor.Transparent else MaterialTheme.colorScheme.surfaceContainer, - containerModifier = if (allowBlur) Modifier - .clip(CircleShape) - .hazeEffect( - state = sheetHazeState, - style = HazeMaterials.regular( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - else Modifier - ) - - // Loading indicator - if (metadataState.value.isLoading) { - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp) - .size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } - - } - } - - // ── Shared composable: Media grid panel ── - val stringToday = stringResource(R.string.header_today) - val stringYesterday = stringResource(R.string.header_yesterday) - - val mediaGridContent: @Composable (Modifier) -> Unit = { modifier -> - MediaGridPanel( - modifier = modifier, - gridState = gridState, - gridItems = gridItems, - stringToday = stringToday, - stringYesterday = stringYesterday, - onMediaClick = { geoMedia -> openMediaViewer(geoMedia) } - ) - } - - // ── Layout: Adaptive (wide = side-by-side, compact = bottom sheet) ──~ - if (useWideLayout) { - Row(modifier = Modifier.fillMaxSize()) { - mapContent( - Modifier - .weight(1f) - .fillMaxHeight() - ) - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surface) - ) { - mediaGridContent(Modifier.fillMaxSize()) - } - } - } else { - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded - ) - ) - - val screenHeight = LocalWindowInfo.current.containerDpSize.height - val sheetMaxHeight = screenHeight / 2 - - // Blur support - val surfaceColorLocal = MaterialTheme.colorScheme.surface - - @OptIn(ExperimentalHazeMaterialsApi::class) - val sheetHazeStyle = HazeMaterials.regular( - containerColor = surfaceColorLocal - ) - val sheetBackgroundModifier = remember(allowBlur, surfaceColorLocal) { - when { - !allowBlur -> Modifier.background( - color = surfaceColorLocal, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - ) - - else -> Modifier - } - } - - // Dynamically update camera padding as sheet is swiped - LaunchedEffect(scaffoldState, mapState.isStyleLoaded) { - if (!mapState.isStyleLoaded) return@LaunchedEffect - snapshotFlow { - runCatching { scaffoldState.bottomSheetState.requireOffset() }.getOrNull() - }.collect { offset -> - if (offset != null) { - val containerHeight = with(density) { screenHeight.toPx() } - val sheetVisiblePx = (containerHeight - offset).coerceAtLeast(0f) - currentSheetPaddingPx = sheetVisiblePx - mapState.setCameraPadding(bottom = sheetVisiblePx.toDouble()) - } - } - } - - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = sheetPeekHeight, - sheetContainerColor = if (allowBlur) ComposeColor.Transparent else MaterialTheme.colorScheme.surface, - sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetDragHandle = {}, - sheetContent = { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) - .then(sheetBackgroundModifier) - .hazeEffect( - state = sheetHazeState, - style = sheetHazeStyle - ) - ) { - BottomSheetDefaults.DragHandle( - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - mediaGridContent( - Modifier - .fillMaxWidth() - .heightIn(max = sheetMaxHeight) - .navigationBarsPadding() - ) - } - } - ) { - mapContent( - Modifier.fillMaxSize() - ) - } - } -} - -/** - * Isolated composable that reads the [mapCaptureState] and renders it as a [hazeSource]. - * Because the capture state updates every ~50 ms, keeping this read in its own composable - * scope prevents recomposition from propagating into the sibling [MaplibreMap]. - */ -@Composable -private fun MapBlurOverlay( - mapCaptureState: State, - hazeState: HazeState, -) { - mapCaptureState.value?.let { bitmap -> - Image( - bitmap = bitmap, - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = Modifier - .fillMaxSize() - .hazeSource(hazeState) - ) - } -} diff --git a/app/src/nomaps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt b/app/src/nomaps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt deleted file mode 100644 index 019e90754f..0000000000 --- a/app/src/nomaps/kotlin/com/dot/gallery/feature_node/presentation/location/MapLocationsContent.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023-2026 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.location - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import com.dot.gallery.feature_node.domain.model.GeoMedia -import com.dot.gallery.feature_node.domain.model.LocationMedia -import com.dot.gallery.feature_node.domain.model.MediaMetadataState - -@Suppress("UNUSED_PARAMETER") -@Composable -internal fun MapLocationsContent( - metadataState: State, - locations: List = emptyList(), - geoMedia: List = emptyList(), - initialMediaId: Long = -1L, -) { - ListLocationsContent(metadataState = metadataState, locations = locations) -} diff --git a/app/src/perf/res/values/ic_launcher_background.xml b/app/src/perf/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..0d5a31cb86 --- /dev/null +++ b/app/src/perf/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FB8C00 + diff --git a/app/src/test/java/com/dot/gallery/ExampleUnitTest.kt b/app/src/test/java/com/dot/gallery/ExampleUnitTest.kt deleted file mode 100644 index dce3d62abf..0000000000 --- a/app/src/test/java/com/dot/gallery/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.dot.gallery - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/com/dot/gallery/core/GalleryPerformanceArchitectureTest.kt b/app/src/test/kotlin/com/dot/gallery/core/GalleryPerformanceArchitectureTest.kt new file mode 100644 index 0000000000..e39b95bfbe --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/GalleryPerformanceArchitectureTest.kt @@ -0,0 +1,84 @@ +package com.dot.gallery.core + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class GalleryPerformanceArchitectureTest { + + @Test + fun mediaCells_doNotCollectSelectorOrMetadataFlows() { + val source = sourceFile( + "feature_node/presentation/common/components/MediaImage.kt", + ).readText() + + assertFalse(source.contains("collectAsState")) + assertFalse(source.contains("LocalMediaSelector")) + assertFalse(source.contains("MediaMetadataState")) + } + + @Test + fun mediaDistributor_doesNotExposeEagerEmbeddingState() { + val contract = sourceFile("core/MediaDistributor.kt").readText() + val implementation = sourceFile("core/MediaDistributorImpl.kt").readText() + + assertFalse(contract.contains("imageEmbeddingsFlow")) + assertFalse(implementation.contains("imageEmbeddingsFlow")) + } + + @Test + fun mosaicLayoutAndDragSelection_keepHeavyWorkOffTheMainPath() { + val mosaicSource = sourceFile( + "feature_node/presentation/common/components/MosaicMediaGrid.kt", + ).readText() + val selectionSource = sourceFile( + "feature_node/presentation/util/MultiSelectExt.kt", + ).readText() + + assertTrue(mosaicSource.contains("withContext(Dispatchers.Default)")) + assertTrue(mosaicSource.contains("derivedStateOf { mappedData.toList() }")) + assertFalse(mosaicSource.contains("val mappedDataSnapshot = mappedData.toList()")) + assertFalse(selectionSource.contains(".indexOf(")) + assertFalse(selectionSource.contains("!!")) + } + + /** + * Dependencies run one way: the domain layer builds on the data layer, never the reverse. A + * data source that reaches back into domain makes the storage and query code inseparable from + * the app's higher-level concepts, so it is caught here rather than at review time. + */ + @Test + fun dataLayer_doesNotDependOnTheDomainLayer() { + val dataSources = sourceFile("feature_node/data") + .walkTopDown() + .filter { file -> file.isFile && file.extension == "kt" } + .toList() + + assertTrue("No data sources found — has the layout moved?", dataSources.isNotEmpty()) + dataSources.forEach { dataSource -> + assertFalse( + "Data source depends on the domain layer: ${dataSource.path}", + dataSource.readText().withoutStringLiterals() + .contains("com.dot.gallery.feature_node.domain"), + ) + } + } + + /** + * Drops string contents so that a domain package name appearing as *data* — a pinned + * `@SerialName`, a stored column default — does not read as a dependency on it. + */ + private fun String.withoutStringLiterals(): String { + return replace(RAW_STRING, "").replace(ESCAPED_STRING, "") + } + + private fun sourceFile(relativePath: String): File { + return File("src/main/kotlin/com/dot/gallery/${relativePath}") + } + + private companion object { + private val RAW_STRING = Regex("\"\"\".*?\"\"\"", RegexOption.DOT_MATCHES_ALL) + private val ESCAPED_STRING = Regex("""["](\\.|[^"\\\n])*["]""") + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifierTest.kt b/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifierTest.kt new file mode 100644 index 0000000000..1f6da7352b --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderClassifierTest.kt @@ -0,0 +1,114 @@ +package com.dot.gallery.core.decoder + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +internal class ImageHeaderClassifierTest { + + @Test + fun classifyImageHeader_svgWithXmlDeclaration_returnsSvg() { + val header = """ + + + + """.trimIndent().encodeToByteArray() + + assertEquals( + ImageFileFormat.SVG, + classifyImageHeader(header = header, length = header.size), + ) + } + + @Test + fun classifyImageHeader_textMentioningSvgWithoutTag_returnsNull() { + val header = "This is not an SVG image".encodeToByteArray() + + assertNull(classifyImageHeader(header = header, length = header.size)) + } + + @Test + fun classifyImageHeader_recognizesStandardImageFormats() { + assertEquals( + ImageFileFormat.JPEG, + classify(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())), + ) + assertEquals( + ImageFileFormat.PNG, + classify( + byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ), + ), + ) + assertEquals(ImageFileFormat.GIF, classify("GIF89a".encodeToByteArray())) + assertEquals( + ImageFileFormat.WEBP, + classify("RIFF0000WEBP".encodeToByteArray()), + ) + assertEquals( + ImageFileFormat.ANIMATED_WEBP, + classify( + "RIFF0000WEBPVP8X00000".encodeToByteArray().apply { + this[20] = 0x02 + }, + ), + ) + } + + @Test + fun classifyImageHeader_recognizesSandboxedFormats() { + assertEquals( + ImageFileFormat.JXL, + classify(byteArrayOf(0xFF.toByte(), 0x0A)), + ) + assertEquals( + ImageFileFormat.AVIF, + classify(ftypBox(primaryBrand = "avif", compatibleBrand = "mif1")), + ) + assertEquals( + ImageFileFormat.HEIF, + classify(ftypBox(primaryBrand = "mif1", compatibleBrand = "heic")), + ) + } + + @Test + fun classifyImageHeader_rejectsUnknownAndMalformedInputs() { + assertNull(classify(ByteArray(size = 0))) + assertNull(classify("not an image".encodeToByteArray())) + assertNull( + classify( + byteArrayOf( + 0x00, + 0x00, + 0x10, + 0x00, + 0x66, + 0x74, + 0x79, + 0x70, + ), + ), + ) + } + + private fun classify(bytes: ByteArray): ImageFileFormat? { + return classifyImageHeader(header = bytes, length = bytes.size) + } + + private fun ftypBox(primaryBrand: String, compatibleBrand: String): ByteArray { + val box = ByteArray(size = 20) + box[3] = box.size.toByte() + "ftyp".encodeToByteArray().copyInto(destination = box, destinationOffset = 4) + primaryBrand.encodeToByteArray().copyInto(destination = box, destinationOffset = 8) + compatibleBrand.encodeToByteArray().copyInto(destination = box, destinationOffset = 16) + return box + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderReaderTest.kt b/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderReaderTest.kt new file mode 100644 index 0000000000..c52553b1c4 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/decoder/ImageHeaderReaderTest.kt @@ -0,0 +1,106 @@ +package com.dot.gallery.core.decoder +import java.io.ByteArrayInputStream +import java.io.InputStream +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ImageHeaderReaderTest { + @Test + fun readImageHeader_fillsHeaderFromOneByteReads() { + val expected = avifHeader() + val inputStream = OneByteInputStream(bytes = expected) + + val header = inputStream.readImageHeader() + + assertArrayEquals(expected, header) + assertEquals( + ImageFileFormat.AVIF, + classifyImageHeader(header = header, length = header.size), + ) + } + + @Test + fun readImageHeader_stopsAtHeaderLimit() { + val input = ByteArray(size = IMAGE_HEADER_BYTES + 10) { index -> index.toByte() } + + val header = ByteArrayInputStream(input).readImageHeader() + + assertEquals(IMAGE_HEADER_BYTES, header.size) + assertArrayEquals(input.copyOf(newSize = IMAGE_HEADER_BYTES), header) + } + + @Test + fun readImageHeader_accumulatesSyntheticShortPreads() { + val expected = avifHeader() + var sourceOffset = 0 + + val header = readImageHeader { destination, offset, length -> + if (sourceOffset == expected.size) { + 0 + } else { + val readBytes = minOf(2, length, expected.size - sourceOffset) + expected.copyInto( + destination = destination, + destinationOffset = offset, + startIndex = sourceOffset, + endIndex = sourceOffset + readBytes, + ) + sourceOffset += readBytes + readBytes + } + } + + assertArrayEquals(expected, header) + assertEquals( + ImageFileFormat.AVIF, + classifyImageHeader(header = header, length = header.size), + ) + } + + private fun avifHeader(): ByteArray { + return byteArrayOf( + 0x00, + 0x00, + 0x00, + 0x14, + 0x66, + 0x74, + 0x79, + 0x70, + 0x61, + 0x76, + 0x69, + 0x66, + 0x00, + 0x00, + 0x00, + 0x00, + 0x6D, + 0x69, + 0x66, + 0x31, + ) + } +} + +private class OneByteInputStream( + private val bytes: ByteArray, +) : InputStream() { + private var offset = 0 + + override fun read(): Int { + return when { + offset == bytes.size -> -1 + else -> bytes[offset++].toInt() and 0xFF + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (this.offset == bytes.size) { + return -1 + } + buffer[offset] = bytes[this.offset++] + return 1 + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoaderTest.kt b/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoaderTest.kt new file mode 100644 index 0000000000..22f6e14fe1 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaModelLoaderTest.kt @@ -0,0 +1,243 @@ +package com.dot.gallery.core.decoder.glide + +import android.content.ContentResolver +import android.net.Uri +import android.os.ParcelFileDescriptor +import com.bumptech.glide.Priority +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.dot.gallery.core.decoder.ImageFileFormat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class GalleryMediaModelLoaderTest { + private val contentResolver = mockk() + private val loader = GalleryMediaModelLoader( + mediaSource = ContentResolverGalleryMediaSource(contentResolver = contentResolver), + ) + private val temporaryFiles = mutableListOf() + + @After + fun deleteTemporaryFiles() { + temporaryFiles.forEach { file -> file.delete() } + } + + @Test + fun loadData_classifiesChunkedAvifWithOneStreamOpen() { + val uri = Uri.parse("content://media/avif") + every { contentResolver.openInputStream(uri) } returns OneByteMediaInputStream(avifHeader()) + + val callback = load(uri = uri, mimeType = "image/avif") + + assertTrue(callback.data is SandboxedImageData) + assertEquals("image/avif", (callback.data as SandboxedImageData).mimeType) + verify(exactly = 1) { contentResolver.openInputStream(uri) } + verify(exactly = 0) { contentResolver.openFileDescriptor(uri, any()) } + callback.data?.close() + } + + @Test + fun loadData_routesStandardImageWithOneStreamOpen() { + val uri = Uri.parse("content://media/jpeg") + every { contentResolver.openInputStream(uri) } returns ByteArrayInputStream(jpegHeader()) + + val callback = load(uri = uri, mimeType = "image/jpeg") + + assertTrue(callback.data is PlatformImageData) + assertEquals(ImageFileFormat.JPEG, (callback.data as PlatformImageData).format) + verify(exactly = 1) { contentResolver.openInputStream(uri) } + callback.data?.close() + } + + @Test + fun loadData_routesDeclaredVideoWithOneDescriptorOpen() { + val uri = Uri.parse("content://media/video") + every { contentResolver.openFileDescriptor(uri, "r") } returns descriptor(mp4Header()) + + val callback = load(uri = uri, mimeType = "video/mp4") + + assertTrue(callback.data is VerifiedVideoData) + verify(exactly = 1) { contentResolver.openFileDescriptor(uri, "r") } + verify(exactly = 0) { contentResolver.openInputStream(uri) } + callback.data?.close() + } + + @Test + fun loadData_resolvesMissingVideoMimeType() { + val uri = Uri.parse("content://media/video-without-declared-mime") + every { contentResolver.getType(uri) } returns "video/mp4" + every { contentResolver.openFileDescriptor(uri, "r") } returns descriptor(mp4Header()) + + val callback = load(uri = uri, mimeType = null) + + assertTrue(callback.data is VerifiedVideoData) + verify(exactly = 1) { contentResolver.getType(uri) } + verify(exactly = 1) { contentResolver.openFileDescriptor(uri, "r") } + verify(exactly = 0) { contentResolver.openInputStream(uri) } + callback.data?.close() + } + + @Test + fun loadData_routesAvifDeclaredAsVideoByBytes() { + val uri = Uri.parse("content://media/mislabeled-avif") + every { contentResolver.openFileDescriptor(uri, "r") } returns descriptor(avifHeader()) + + val callback = load(uri = uri, mimeType = "video/mp4") + + assertTrue(callback.data is SandboxedImageData) + assertEquals("image/avif", (callback.data as SandboxedImageData).mimeType) + verify(exactly = 1) { contentResolver.openFileDescriptor(uri, "r") } + verify(exactly = 0) { contentResolver.openInputStream(uri) } + callback.data?.close() + } + + @Test + fun loadData_routesJpegDeclaredAsVideoByBytes() { + val uri = Uri.parse("content://media/mislabeled-jpeg") + every { contentResolver.openFileDescriptor(uri, "r") } returns descriptor(jpegHeader()) + + val callback = load(uri = uri, mimeType = "video/mp4") + + assertTrue(callback.data is PlatformImageData) + assertEquals(ImageFileFormat.JPEG, (callback.data as PlatformImageData).format) + verify(exactly = 1) { contentResolver.openFileDescriptor(uri, "r") } + callback.data?.close() + } + + @Test + fun loadData_rejectsUnknownNonVideoContent() { + val uri = Uri.parse("content://media/unknown") + every { contentResolver.openInputStream(uri) } returns + ByteArrayInputStream("not media".encodeToByteArray()) + + val callback = load(uri = uri, mimeType = "application/octet-stream") + + assertNotNull(callback.failure) + verify(exactly = 1) { contentResolver.openInputStream(uri) } + } + + private fun load(uri: Uri, mimeType: String?): RecordingCallback { + val loadData = loader.buildLoadData( + model = GalleryMediaModel( + uri = uri, + declaredMimeType = mimeType, + ), + width = 100, + height = 100, + options = Options(), + ) + val callback = RecordingCallback() + loadData.fetcher.loadData(Priority.NORMAL, callback) + return callback + } + + private fun descriptor(bytes: ByteArray): ParcelFileDescriptor { + val file = File.createTempFile("gallery-media-loader", ".bin") + temporaryFiles += file + file.writeBytes(bytes) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + private fun jpegHeader(): ByteArray { + return byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) + } + + private fun avifHeader(): ByteArray { + return byteArrayOf( + 0x00, + 0x00, + 0x00, + 0x14, + 0x66, + 0x74, + 0x79, + 0x70, + 0x61, + 0x76, + 0x69, + 0x66, + 0x00, + 0x00, + 0x00, + 0x00, + 0x6D, + 0x69, + 0x66, + 0x31, + ) + } + + private fun mp4Header(): ByteArray { + return byteArrayOf( + 0x00, + 0x00, + 0x00, + 0x18, + 0x66, + 0x74, + 0x79, + 0x70, + 0x69, + 0x73, + 0x6F, + 0x6D, + 0x00, + 0x00, + 0x00, + 0x00, + 0x69, + 0x73, + 0x6F, + 0x6D, + 0x6D, + 0x70, + 0x34, + 0x32, + ) + } +} + +private class RecordingCallback : DataFetcher.DataCallback { + var data: GalleryMediaData? = null + private set + var failure: Exception? = null + private set + + override fun onDataReady(data: GalleryMediaData?) { + this.data = data + } + + override fun onLoadFailed(exception: Exception) { + failure = exception + } +} + +private class OneByteMediaInputStream( + private val delegate: InputStream, +) : InputStream() { + constructor(bytes: ByteArray) : this(delegate = ByteArrayInputStream(bytes)) + + override fun read(): Int { + return delegate.read() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return delegate.read(buffer, offset, minOf(length, 1)) + } + + override fun close() { + delegate.close() + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingArchitectureTest.kt b/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingArchitectureTest.kt new file mode 100644 index 0000000000..5231d68292 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/decoder/glide/GalleryMediaRoutingArchitectureTest.kt @@ -0,0 +1,40 @@ +package com.dot.gallery.core.decoder.glide + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class GalleryMediaRoutingArchitectureTest { + @Test + fun zoomablePagerImage_registersVerifiedGallerySubsamplingGenerator() { + val source = findAppDirectory() + .resolve( + "src/main/kotlin/com/dot/gallery/feature_node/presentation/" + + "mediaview/components/media/ZoomablePagerImage.kt", + ) + .readText() + + assertTrue( + "ZoomablePagerImage must construct the verified subsampling generator list", + "galleryMediaSubsamplingImageGenerators()" in source, + ) + assertTrue( + "ZoomablePagerImage must register its verified generator list with ZoomImage", + Regex( + pattern = """rememberGlideZoomState\s*\(\s*""" + + """subsamplingImageGenerators\s*=\s*subsamplingImageGenerators\s*\)""", + ).containsMatchIn(input = source), + ) + } + + private fun findAppDirectory(): File { + val workingDirectory = requireNotNull(System.getProperty("user.dir")) { + "Working directory is unavailable" + } + return generateSequence(File(workingDirectory)) { directory -> + directory.parentFile + }.first { directory -> + directory.resolve("src/main/kotlin/com/dot/gallery").isDirectory + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransferTest.kt b/app/src/test/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransferTest.kt new file mode 100644 index 0000000000..42281b141c --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/sandbox/EncodedMediaTransferTest.kt @@ -0,0 +1,109 @@ +package com.dot.gallery.core.sandbox + +import com.dot.gallery.core.util.SizeLimitExceededException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class EncodedMediaTransferTest { + @Test + fun transferEncodedMedia_streamsInputWithoutMaterializingSource() { + val inputStream = GeneratedInputStream(byteCount = 1_000_000L) + val outputStream = CountingOutputStream() + + val transferredBytes = transferEncodedMedia( + inputStream = inputStream, + outputStream = outputStream, + maximumBytes = 1_000_000L, + ) + + assertEquals(1_000_000L, transferredBytes) + assertEquals(1_000_000L, outputStream.byteCount) + } + + @Test + fun transferEncodedMedia_rejectsLazyInputBeyondLimit() { + val inputStream = GeneratedInputStream(byteCount = 1_000_001L) + + assertThrows(SizeLimitExceededException::class.java) { + transferEncodedMedia( + inputStream = inputStream, + outputStream = CountingOutputStream(), + maximumBytes = 1_000_000L, + ) + } + } + + @Test + fun transferEncodedMedia_stopsReadingAfterEarlyOutputFailure() { + val inputStream = GeneratedInputStream(byteCount = 1_000_000L) + + assertThrows(IOException::class.java) { + transferEncodedMedia( + inputStream = inputStream, + outputStream = FailingOutputStream(), + maximumBytes = 1_000_000L, + ) + } + + assertTrue(inputStream.bytesRead <= EXPECTED_TRANSFER_BUFFER_BYTES) + } +} + +private class GeneratedInputStream( + private val byteCount: Long, +) : InputStream() { + private var position = 0L + + val bytesRead: Long + get() = position + + override fun read(): Int { + return when { + position == byteCount -> -1 + else -> { + position++ + 0 + } + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val remainingBytes = byteCount - position + if (remainingBytes == 0L) { + return -1 + } + val readBytes = minOf(length.toLong(), remainingBytes).toInt() + position += readBytes + return readBytes + } +} + +private class FailingOutputStream : OutputStream() { + override fun write(value: Int) { + throw IOException("Synthetic output failure") + } + + override fun write(buffer: ByteArray, offset: Int, length: Int) { + throw IOException("Synthetic output failure") + } +} + +private class CountingOutputStream : OutputStream() { + var byteCount = 0L + private set + + override fun write(value: Int) { + byteCount++ + } + + override fun write(buffer: ByteArray, offset: Int, length: Int) { + byteCount += length + } +} + +private const val EXPECTED_TRANSFER_BUFFER_BYTES = 64L * 1024L diff --git a/app/src/test/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnectionTest.kt b/app/src/test/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnectionTest.kt new file mode 100644 index 0000000000..63f7f28482 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/sandbox/IsolatedDecoderConnectionTest.kt @@ -0,0 +1,122 @@ +package com.dot.gallery.core.sandbox + +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Binder +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class IsolatedDecoderConnectionTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val context = mockk() + private val serviceConnection = slot() + + @Test + fun transientDisconnect_keepsRegisteredBindingAndWaitsForReconnect() { + runTest { + every { context.packageName } returns "com.dot.gallery" + every { + context.bindService( + any(), + capture(serviceConnection), + any(), + ) + } returns true + every { context.unbindService(any()) } just Runs + val connection = IsolatedDecoderConnection(context = context) + + val firstRequest = async { connection.getMessenger() } + runCurrent() + serviceConnection.captured.onServiceConnected(null, Binder()) + assertNotNull(firstRequest.await()) + + serviceConnection.captured.onServiceDisconnected(null) + val reconnectingRequest = async { connection.getMessenger() } + runCurrent() + verify(exactly = 1) { + context.bindService(any(), any(), any()) + } + + serviceConnection.captured.onServiceConnected(null, Binder()) + assertNotNull(reconnectingRequest.await()) + verify(exactly = 0) { context.unbindService(any()) } + } + } + + @Test + fun bindingDeath_releasesBindingBeforeRegisteringAnother() { + runTest { + every { context.packageName } returns "com.dot.gallery" + every { + context.bindService( + any(), + capture(serviceConnection), + any(), + ) + } returns true + every { context.unbindService(any()) } just Runs + val connection = IsolatedDecoderConnection(context = context) + + val firstRequest = async { connection.getMessenger() } + runCurrent() + serviceConnection.captured.onServiceConnected(null, Binder()) + assertNotNull(firstRequest.await()) + + serviceConnection.captured.onBindingDied(null) + verify(exactly = 1) { context.unbindService(any()) } + + val replacementRequest = async { connection.getMessenger() } + runCurrent() + verify(exactly = 2) { + context.bindService(any(), any(), any()) + } + serviceConnection.captured.onServiceConnected(null, Binder()) + assertNotNull(replacementRequest.await()) + } + } + + @Test + fun bindingTimeout_releasesRegisteredBinding() { + runTest { + every { context.packageName } returns "com.dot.gallery" + every { + context.bindService( + any(), + capture(serviceConnection), + any(), + ) + } returns true + every { context.unbindService(any()) } just Runs + val connection = IsolatedDecoderConnection(context = context) + + val request = async { connection.getMessenger() } + runCurrent() + advanceTimeBy(delayTimeMillis = 5_000L) + runCurrent() + + assertNull(request.await()) + verify(exactly = 1) { context.unbindService(serviceConnection.captured) } + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoderCancellationTest.kt b/app/src/test/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoderCancellationTest.kt new file mode 100644 index 0000000000..ca4d58ed5b --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/sandbox/MediaPreviewDecoderCancellationTest.kt @@ -0,0 +1,213 @@ +package com.dot.gallery.core.sandbox + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.os.CancellationSignal +import android.os.OperationCanceledException +import android.os.ParcelFileDescriptor +import android.util.Size +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class MediaPreviewDecoderCancellationTest { + + @Test + fun decode_callerTimeoutCancelsDescriptorOpenPromptly() { + runBlocking { + val contentResolver = mockk() + val openStarted = CompletableDeferred() + every { + contentResolver.openFileDescriptor(TEST_URI, "r", any()) + } answers { + val cancellationSignal = arg(2) + openStarted.complete(cancellationSignal) + awaitCancellation(cancellationSignal = cancellationSignal) + throw OperationCanceledException() + } + val decoder = MediaPreviewDecoder( + connection = mockk(relaxed = true), + contentResolver = contentResolver, + imageDecoder = mockk(relaxed = true), + ioDispatcher = Dispatchers.IO, + ) + + val decode = async { + runCatching { + withTimeout(timeMillis = SHORT_TIMEOUT_MILLIS) { + decoder.decode(uri = TEST_URI, mimeType = "video/mp4", isVideo = true) + } + }.exceptionOrNull() + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + openStarted.await() + } + val failure = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { decode.await() } + + assertTrue(failure is TimeoutCancellationException) + assertTrue(cancellationSignal.isCanceled) + verify(exactly = 1) { + contentResolver.openFileDescriptor(TEST_URI, "r", cancellationSignal) + } + } + } + + @Test + fun loadThumbnailCancellable_cancellationCancelsProviderSignal() { + runBlocking { + val contentResolver = mockk() + val loadStarted = CompletableDeferred() + every { + contentResolver.loadThumbnail(TEST_URI, TEST_SIZE, any()) + } answers { + val cancellationSignal = arg(2) + loadStarted.complete(cancellationSignal) + awaitCancellation(cancellationSignal = cancellationSignal) + throw OperationCanceledException() + } + + val load = async(Dispatchers.IO) { + contentResolver.loadThumbnailCancellable(uri = TEST_URI, size = TEST_SIZE) + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + loadStarted.await() + } + + load.cancel() + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { load.join() } + + assertTrue(cancellationSignal.isCanceled) + } + } + + @Test + fun loadThumbnailCancellable_timeoutCancelsProviderSignal() { + runBlocking { + val contentResolver = mockk() + val loadStarted = CompletableDeferred() + every { + contentResolver.loadThumbnail(TEST_URI, TEST_SIZE, any()) + } answers { + val cancellationSignal = arg(2) + loadStarted.complete(cancellationSignal) + awaitCancellation(cancellationSignal = cancellationSignal) + throw OperationCanceledException() + } + + val load = async(Dispatchers.IO) { + runCatching { + withTimeout(timeMillis = SHORT_TIMEOUT_MILLIS) { + contentResolver.loadThumbnailCancellable( + uri = TEST_URI, + size = TEST_SIZE, + ) + } + }.exceptionOrNull() + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + loadStarted.await() + } + val failure = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { load.await() } + + assertTrue(failure is TimeoutCancellationException) + assertTrue(cancellationSignal.isCanceled) + } + } + + @Test + fun openFileDescriptorCancellable_lateDescriptorIsClosed() { + runBlocking { + val contentResolver = mockk() + val openStarted = CompletableDeferred() + val allowResult = CountDownLatch(1) + val descriptor = ParcelFileDescriptor.open( + File("/dev/null"), + ParcelFileDescriptor.MODE_READ_ONLY, + ) + every { + contentResolver.openFileDescriptor(TEST_URI, "r", any()) + } answers { + openStarted.complete(arg(n = 2)) + check(allowResult.await(TEST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) + descriptor + } + + val open = async(Dispatchers.IO) { + contentResolver.openFileDescriptorCancellable(uri = TEST_URI, mode = "r") + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + openStarted.await() + } + open.cancel() + assertTrue(cancellationSignal.isCanceled) + + allowResult.countDown() + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { open.join() } + + assertFalse(descriptor.fileDescriptor.valid()) + } + } + + @Test + fun loadThumbnailCancellable_lateBitmapIsRecycled() { + runBlocking { + val contentResolver = mockk() + val loadStarted = CompletableDeferred() + val allowResult = CountDownLatch(1) + val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + every { + contentResolver.loadThumbnail(TEST_URI, TEST_SIZE, any()) + } answers { + loadStarted.complete(arg(n = 2)) + check(allowResult.await(TEST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) + bitmap + } + + val load = async(Dispatchers.IO) { + contentResolver.loadThumbnailCancellable(uri = TEST_URI, size = TEST_SIZE) + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + loadStarted.await() + } + load.cancel() + assertTrue(cancellationSignal.isCanceled) + + allowResult.countDown() + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { load.join() } + + assertTrue(bitmap.isRecycled) + } + } + + private fun awaitCancellation(cancellationSignal: CancellationSignal) { + val deadlineNanoseconds = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(TEST_TIMEOUT_MILLIS) + while (!cancellationSignal.isCanceled && System.nanoTime() < deadlineNanoseconds) { + Thread.yield() + } + check(cancellationSignal.isCanceled) + } + + private companion object { + private const val SHORT_TIMEOUT_MILLIS = 100L + private const val TEST_TIMEOUT_MILLIS = 5_000L + private val TEST_SIZE = Size(224, 224) + private val TEST_URI = Uri.parse("content://media/external/video/media/1") + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/sandbox/SandboxDecoderArchitectureTest.kt b/app/src/test/kotlin/com/dot/gallery/core/sandbox/SandboxDecoderArchitectureTest.kt new file mode 100644 index 0000000000..ea0bbc7ab4 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/sandbox/SandboxDecoderArchitectureTest.kt @@ -0,0 +1,64 @@ +package com.dot.gallery.core.sandbox + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Test + +internal class SandboxDecoderArchitectureTest { + @Test + fun sandboxClientEntryPoints_doNotReadWholeSourcesEagerly() { + val sourceRoot = findAppDirectory().resolve("src/main/kotlin/com/dot/gallery") + val sourceFiles = listOf( + sourceRoot.resolve("core/sandbox/IsolatedImageDecoder.kt"), + sourceRoot.resolve("core/sandbox/MediaPreviewDecoder.kt"), + sourceRoot.resolve("core/sandbox/EncodedMediaTransfer.kt"), + sourceRoot.resolve("core/decoder/SandboxedSketchHeifDecoder.kt"), + sourceRoot.resolve("core/decoder/SandboxedSketchJxlDecoder.kt"), + sourceRoot.resolve("core/decoder/glide/GalleryMediaModelLoader.kt"), + sourceRoot.resolve("core/decoder/glide/SandboxedGalleryImageDecoder.kt"), + ) + + sourceFiles.forEach { sourceFile -> + val source = sourceFile.readText() + assertFalse("${sourceFile.name} must not call readBytes()", ".readBytes(" in source) + assertFalse("${sourceFile.name} must not call readAllBytes()", ".readAllBytes(" in source) + } + } + + @Test + fun aiAnalysisEntryPoints_doNotDecodeMediaInTheApplicationProcess() { + val sourceRoot = findAppDirectory().resolve("src/main/kotlin/com/dot/gallery") + val sourceFiles = listOf( + sourceRoot.resolve("core/workers/SearchIndexerUpdaterWorker.kt"), + sourceRoot.resolve("feature_node/presentation/search/SearchViewModel.kt"), + ) + val forbiddenCalls = listOf( + "BitmapFactory", + "ImageRequest(", + ".readBytes(", + ".readAllBytes(", + ".sketch", + ) + + sourceFiles.forEach { sourceFile -> + val source = sourceFile.readText() + forbiddenCalls.forEach { forbiddenCall -> + assertFalse( + "${sourceFile.name} must not use $forbiddenCall", + forbiddenCall in source, + ) + } + } + } + + private fun findAppDirectory(): File { + val workingDirectory = requireNotNull(System.getProperty("user.dir")) { + "Working directory is unavailable" + } + return generateSequence(File(workingDirectory)) { directory -> + directory.parentFile + }.first { directory -> + directory.resolve("src/main/kotlin/com/dot/gallery").isDirectory + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/util/SizeLimitedInputStreamTest.kt b/app/src/test/kotlin/com/dot/gallery/core/util/SizeLimitedInputStreamTest.kt new file mode 100644 index 0000000000..5f41d9a6b2 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/util/SizeLimitedInputStreamTest.kt @@ -0,0 +1,49 @@ +package com.dot.gallery.core.util + +import java.io.ByteArrayInputStream +import java.io.IOException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SizeLimitedInputStreamTest { + + @Test + fun `read permits input exactly at limit`() { + val bytes = ByteArray(16) { index -> index.toByte() } + val result = SizeLimitedInputStream( + inputStream = ByteArrayInputStream(bytes), + maximumBytes = bytes.size.toLong(), + ).use { inputStream -> + inputStream.readBytes() + } + + assertArrayEquals(bytes, result) + } + + @Test + fun `read rejects input beyond limit`() { + val inputStream = SizeLimitedInputStream( + inputStream = ByteArrayInputStream(ByteArray(17)), + maximumBytes = 16L, + ) + + assertThrows(IOException::class.java) { + inputStream.use { stream -> stream.readBytes() } + } + } + + @Test + fun `skip contributes to consumed byte count`() { + val inputStream = SizeLimitedInputStream( + inputStream = ByteArrayInputStream(ByteArray(17)), + maximumBytes = 16L, + ) + + assertEquals(12L, inputStream.skip(12L)) + assertThrows(IOException::class.java) { + inputStream.read(ByteArray(5)) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverInsertTest.kt b/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverInsertTest.kt new file mode 100644 index 0000000000..da6aa40413 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverInsertTest.kt @@ -0,0 +1,78 @@ +package com.dot.gallery.core.util.ext + +import android.content.ContentResolver +import android.content.ContentValues +import android.net.Uri +import android.provider.MediaStore +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verifyOrder +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ContentResolverInsertTest { + + @Test + fun saveRawStream_insertsPendingWritesThenPublishes() { + runBlocking { + val contentResolver = mockk() + val destinationUri = Uri.parse("content://media/external/images/media/1") + val insertedValues = slot() + val publishedValues = slot() + val outputStream = ByteArrayOutputStream() + every { + contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + capture(insertedValues), + ) + } returns destinationUri + every { contentResolver.openOutputStream(destinationUri) } returns outputStream + every { + contentResolver.update( + destinationUri, + capture(publishedValues), + null, + null, + ) + } answers { + assertEquals( + 1, + insertedValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + assertEquals(EXPECTED_BYTES.toList(), outputStream.toByteArray().toList()) + 1 + } + + val result = contentResolver.saveRawStream( + writeBlock = { stream -> stream.write(EXPECTED_BYTES) }, + mimeType = "image/png", + displayName = "test.png", + ) + + assertEquals(destinationUri, result) + assertEquals(1, insertedValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING)) + assertEquals(0, publishedValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING)) + assertNotNull(publishedValues.captured.getAsLong(MediaStore.MediaColumns.DATE_MODIFIED)) + assertEquals(EXPECTED_BYTES.toList(), outputStream.toByteArray().toList()) + verifyOrder { + contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + any(), + ) + contentResolver.openOutputStream(destinationUri) + contentResolver.update(destinationUri, any(), null, null) + } + } + } + + private companion object { + private val EXPECTED_BYTES = byteArrayOf(1, 2, 3) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverQueryFlowTest.kt b/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverQueryFlowTest.kt new file mode 100644 index 0000000000..3c04134736 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/util/ext/ContentResolverQueryFlowTest.kt @@ -0,0 +1,211 @@ +package com.dot.gallery.core.util.ext + +import android.content.ContentResolver +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import android.os.CancellationSignal +import android.os.OperationCanceledException +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ContentResolverQueryFlowTest { + + @Test + fun queryFlow_coalescesBurstInvalidations() { + runBlocking { + val contentResolver = mockk(relaxed = true) + val observerSlot = slot() + val observerRegistered = CompletableDeferred() + val firstQueryCompleted = CompletableDeferred() + val firstCursorReceived = CompletableDeferred() + val queryCount = AtomicInteger() + every { + contentResolver.registerContentObserver(TEST_URI, true, capture(observerSlot)) + } answers { + observerRegistered.complete(Unit) + } + every { + contentResolver.query(TEST_URI, null, null, any()) + } answers { + if (queryCount.incrementAndGet() == 1) { + firstQueryCompleted.complete(Unit) + } + mockk(relaxed = true) + } + + val result = async { + contentResolver.queryFlow(uri = TEST_URI, queryArgs = null) + .onEach { firstCursorReceived.complete(Unit) } + .take(count = 2) + .toList() + } + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + observerRegistered.await() + firstQueryCompleted.await() + firstCursorReceived.await() + } + + repeat(times = 5) { + observerSlot.captured.onChange(false) + } + + delay(timeMillis = 500L) + assertEquals(2, queryCount.get()) + val cursors = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { result.await() } + cursors.forEach { cursor -> cursor?.close() } + verify(exactly = 1) { contentResolver.unregisterContentObserver(observerSlot.captured) } + } + } + + @Test + fun queryFlow_cancelsStaleQueryBeforeDebouncedReplacement() { + runBlocking { + val contentResolver = mockk(relaxed = true) + val observerSlot = slot() + val observerRegistered = CompletableDeferred() + val firstQueryStarted = CompletableDeferred() + val queryCount = AtomicInteger() + every { + contentResolver.registerContentObserver(TEST_URI, true, capture(observerSlot)) + } answers { + observerRegistered.complete(Unit) + } + every { + contentResolver.query(TEST_URI, null, null, any()) + } answers { + val cancellationSignal = arg(3) + if (queryCount.incrementAndGet() == 1) { + firstQueryStarted.complete(cancellationSignal) + awaitCancellation(cancellationSignal = cancellationSignal) + throw OperationCanceledException() + } + mockk(relaxed = true) + } + + val result = async { + contentResolver.queryFlow(uri = TEST_URI, queryArgs = null) + .take(count = 1) + .toList() + } + val staleCancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + observerRegistered.await() + firstQueryStarted.await() + } + + observerSlot.captured.onChange(false) + + val cursors = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { result.await() } + cursors.forEach { cursor -> cursor?.close() } + assertTrue(staleCancellationSignal.isCanceled) + assertEquals(2, queryCount.get()) + verify(exactly = 1) { contentResolver.unregisterContentObserver(observerSlot.captured) } + } + } + + @Test + fun queryFlow_collectorCancellationCancelsQueryAndUnregistersObserver() { + runBlocking { + val contentResolver = mockk(relaxed = true) + val observerSlot = slot() + val queryStarted = CompletableDeferred() + every { + contentResolver.registerContentObserver(TEST_URI, true, capture(observerSlot)) + } returns Unit + every { + contentResolver.query(TEST_URI, null, null, any()) + } answers { + val cancellationSignal = arg(3) + queryStarted.complete(cancellationSignal) + awaitCancellation(cancellationSignal = cancellationSignal) + throw OperationCanceledException() + } + + val collection = async { + contentResolver.queryFlow(uri = TEST_URI, queryArgs = null).collect {} + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + queryStarted.await() + } + + collection.cancel() + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { collection.join() } + + assertTrue(cancellationSignal.isCanceled) + verify(exactly = 1) { + contentResolver.unregisterContentObserver(observerSlot.captured) + } + } + } + + @Test + fun queryFlow_lateCursorIsClosedAfterCollectorCancellation() { + runBlocking { + val contentResolver = mockk(relaxed = true) + val observerSlot = slot() + val queryStarted = CompletableDeferred() + val allowResult = CountDownLatch(1) + val cursor = mockk(relaxed = true) + every { + contentResolver.registerContentObserver(TEST_URI, true, capture(observerSlot)) + } returns Unit + every { + contentResolver.query(TEST_URI, null, null, any()) + } answers { + queryStarted.complete(arg(n = 3)) + check(allowResult.await(TEST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) + cursor + } + + val collection = async { + contentResolver.queryFlow(uri = TEST_URI, queryArgs = null).collect {} + } + val cancellationSignal = withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { + queryStarted.await() + } + collection.cancel() + assertTrue(cancellationSignal.isCanceled) + + allowResult.countDown() + withTimeout(timeMillis = TEST_TIMEOUT_MILLIS) { collection.join() } + + verify(exactly = 1) { cursor.close() } + verify(exactly = 1) { + contentResolver.unregisterContentObserver(observerSlot.captured) + } + } + } + + private fun awaitCancellation(cancellationSignal: CancellationSignal) { + val deadlineNanoseconds = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(TEST_TIMEOUT_MILLIS) + while (!cancellationSignal.isCanceled && System.nanoTime() < deadlineNanoseconds) { + Thread.yield() + } + check(cancellationSignal.isCanceled) + } + + private companion object { + private const val TEST_TIMEOUT_MILLIS = 5_000L + private val TEST_URI: Uri = Uri.parse("content://media/external/images/media") + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorkerTest.kt b/app/src/test/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorkerTest.kt new file mode 100644 index 0000000000..96d75236b2 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/workers/AiMediaAnalysisCleanupWorkerTest.kt @@ -0,0 +1,170 @@ +package com.dot.gallery.core.workers + +import android.app.Application +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import com.dot.gallery.feature_node.data.model.AiMediaAnalysisPreferences +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Config(application = Application::class) +@RunWith(RobolectricTestRunner::class) +internal class AiMediaAnalysisCleanupWorkerTest { + + @Test + fun pendingAnalysisCleanup_clearsAllGeneratedDataAndCompletesPendingState() { + runTest { + val repository = FakeRepository( + initialPreferences = preferences(analysisCleanupPending = true), + ) + + val result = buildWorker(repository = repository).doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(1, repository.allCleanupCount) + assertEquals(0, repository.categoryCleanupCount) + assertEquals(false, repository.currentPreferences.analysisCleanupPending) + assertEquals(false, repository.currentPreferences.categoryCleanupPending) + } + } + + @Test + fun pendingCategoryCleanup_clearsOnlyGeneratedCategoryData() { + runTest { + val repository = FakeRepository( + initialPreferences = preferences(categoryCleanupPending = true), + ) + + val result = buildWorker(repository = repository).doWork() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(0, repository.allCleanupCount) + assertEquals(1, repository.categoryCleanupCount) + assertEquals(false, repository.currentPreferences.categoryCleanupPending) + } + } + + @Test + fun cleanupFailure_retriesAndKeepsPendingState() { + runTest { + val repository = FakeRepository( + initialPreferences = preferences(analysisCleanupPending = true), + failCleanup = true, + ) + + val result = buildWorker(repository = repository).doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(true, repository.currentPreferences.analysisCleanupPending) + } + } + + private fun buildWorker(repository: AiMediaAnalysisRepository): AiMediaAnalysisCleanupWorker { + val context: Context = RuntimeEnvironment.getApplication() + return TestListenableWorkerBuilder(context = context) + .setWorkerFactory( + object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker { + return AiMediaAnalysisCleanupWorker( + repository = repository, + appContext = appContext, + workerParams = workerParameters, + ) + } + }, + ) + .build() + } + + private class FakeRepository( + initialPreferences: AiMediaAnalysisPreferences, + private val failCleanup: Boolean = false, + ) : AiMediaAnalysisRepository { + private val preferencesFlow = MutableStateFlow(initialPreferences) + var allCleanupCount = 0 + var categoryCleanupCount = 0 + + val currentPreferences: AiMediaAnalysisPreferences + get() = preferencesFlow.value + + override val preferences: Flow = preferencesFlow + + override suspend fun getPreferences(): AiMediaAnalysisPreferences { + return preferencesFlow.value + } + + override suspend fun needsMigration(): Boolean { + return false + } + + override suspend fun completeMigration() { + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + } + + override suspend fun completeAnalysisCleanup() { + preferencesFlow.value = preferencesFlow.value.copy( + analysisCleanupPending = false, + categoryCleanupPending = false, + ) + } + + override suspend fun completeCategoryCleanup() { + preferencesFlow.value = preferencesFlow.value.copy(categoryCleanupPending = false) + } + + override suspend fun invalidateGeneratedData(mediaIds: Set) { + } + + override suspend fun removeMediaData(mediaIds: Set) { + } + + override suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List { + return emptyList() + } + + override suspend fun clearAllGeneratedData() { + if (failCleanup) { + error("cleanup failed") + } + allCleanupCount++ + } + + override suspend fun clearCategoryGeneratedData() { + categoryCleanupCount++ + } + } + + companion object { + private fun preferences( + analysisCleanupPending: Boolean = false, + categoryCleanupPending: Boolean = false, + ): AiMediaAnalysisPreferences { + return AiMediaAnalysisPreferences( + analysisEnabled = false, + categoryClassificationEnabled = true, + analysisCleanupPending = analysisCleanupPending, + categoryCleanupPending = categoryCleanupPending, + ) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopySchedulerTest.kt b/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopySchedulerTest.kt new file mode 100644 index 0000000000..d70107d8c5 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopySchedulerTest.kt @@ -0,0 +1,380 @@ +package com.dot.gallery.core.workers + +import androidx.core.net.toUri +import androidx.work.OneTimeWorkRequest +import androidx.work.Operation +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.google.common.util.concurrent.SettableFuture +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class MediaCopySchedulerTest { + + @Test + fun prepareBatch_doesNotEnqueueAndReturnsChunkedBatch() { + val workManager = mockk(relaxed = true) + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + + val batch = scheduler.prepareBatch(requests = copyRequests(count = 33)) + + assertTrue(batch.tag.startsWith("MediaCopyBatch_")) + assertEquals(2, batch.workRequestCount) + assertEquals(33, batch.itemCount) + verify(exactly = 0) { workManager.enqueue(any>()) } + } + + @Test + fun enqueue_waitsForOperationAndUsesPreparedBatchTag() { + runTest { + val operationResult = SettableFuture.create() + val workRequests = slot>() + val workManager = mockk() + every { workManager.enqueue(capture(workRequests)) } returns operation( + result = operationResult, + ) + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + val requests = copyRequests(count = 33) + val batch = scheduler.prepareBatch(requests = requests) + + val enqueueResult = async { + scheduler.enqueue( + batch = batch, + requests = requests, + ) + } + runCurrent() + + assertFalse(enqueueResult.isCompleted) + operationResult.set(mockk()) + runCurrent() + + enqueueResult.await() + assertEquals(2, workRequests.captured.size) + assertTrue(workRequests.captured.all { workRequest -> batch.tag in workRequest.tags }) + } + } + + @Test + fun enqueue_whenOperationFails_completesWithTrackablePreparedBatch() { + runTest { + val operationResult = SettableFuture.create() + val workManager = mockk() + every { workManager.enqueue(any>()) } returns operation( + result = operationResult, + ) + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + val requests = copyRequests(count = 1) + val batch = scheduler.prepareBatch(requests = requests) + operationResult.setException(IllegalStateException("enqueue failed")) + + scheduler.enqueue( + batch = batch, + requests = requests, + ) + + assertEquals(1, batch.workRequestCount) + assertEquals(1, batch.itemCount) + } + } + + @Test + fun enqueue_whenOperationFailsAndWorkFinished_observesActualStatus() { + runTest { + val operationResult = SettableFuture.create() + val workManager = mockk() + every { workManager.enqueue(any>()) } returns operation( + result = operationResult, + ) + every { workManager.getWorkInfosByTagFlow(any()) } returns flow { + emit( + listOf( + workInfo( + state = WorkInfo.State.SUCCEEDED, + successfulCount = 1, + ), + ), + ) + } + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + val requests = copyRequests(count = 1) + val batch = scheduler.prepareBatch(requests = requests) + operationResult.setException(IllegalStateException("enqueue failed")) + + scheduler.enqueue( + batch = batch, + requests = requests, + ) + val status = scheduler.observe(batch = batch).first() + + assertEquals( + MediaCopyBatchStatus.Finished( + copiedCount = 1, + failedCount = 0, + successful = true, + ), + status, + ) + } + } + + @Test + fun enqueue_whenOperationIsCancelled_propagatesCancellation() { + runTest { + val operationResult = SettableFuture.create() + val workManager = mockk() + every { workManager.enqueue(any>()) } returns operation( + result = operationResult, + ) + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + val requests = copyRequests(count = 1) + val batch = scheduler.prepareBatch(requests = requests) + operationResult.setException(CancellationException("cancelled")) + + val exception = runCatching { + scheduler.enqueue( + batch = batch, + requests = requests, + ) + }.exceptionOrNull() + + assertEquals(CancellationException::class.java, exception?.javaClass) + } + } + + @Test + fun enqueue_withMismatchedItemCount_rejectsBatch() { + runTest { + val scheduler = MediaCopySchedulerImpl(workManager = mockk(relaxed = true)) + val requests = copyRequests(count = 1) + + val exception = runCatching { + scheduler.enqueue( + batch = batch(itemCount = 2), + requests = requests, + ) + }.exceptionOrNull() + + assertEquals(IllegalArgumentException::class.java, exception?.javaClass) + } + } + + @Test + fun enqueue_withMismatchedWorkRequestCount_rejectsBatch() { + runTest { + val scheduler = MediaCopySchedulerImpl(workManager = mockk(relaxed = true)) + val requests = copyRequests(count = 1) + + val exception = runCatching { + scheduler.enqueue( + batch = batch(workRequestCount = 2), + requests = requests, + ) + }.exceptionOrNull() + + assertEquals(IllegalArgumentException::class.java, exception?.javaClass) + } + } + + @Test + fun toStatus_withMissingWorkInfo_isUnavailable() { + val status = toStatus( + batch = batch(workRequestCount = 2), + workInfos = listOf(workInfo(state = WorkInfo.State.RUNNING)), + ) + + assertEquals(MediaCopyBatchStatus.Unavailable, status) + } + + @Test + fun toStatus_withEmptyRestoredResult_isUnavailable() { + val status = toStatus( + batch = batch(workRequestCount = 1), + workInfos = emptyList(), + ) + + assertEquals(MediaCopyBatchStatus.Unavailable, status) + } + + @Test + fun toStatus_withExcessWorkInfo_isUnavailable() { + val status = toStatus( + batch = batch(workRequestCount = 1), + workInfos = listOf( + workInfo(state = WorkInfo.State.RUNNING), + workInfo(state = WorkInfo.State.RUNNING), + ), + ) + + assertEquals(MediaCopyBatchStatus.Unavailable, status) + } + + @Test + fun toStatus_withActiveWork_calculatesProgress() { + val status = toStatus( + batch = batch(workRequestCount = 2), + workInfos = listOf( + workInfo(state = WorkInfo.State.SUCCEEDED), + workInfo(state = WorkInfo.State.RUNNING, progress = 50), + ), + ) + + assertEquals(MediaCopyBatchStatus.Copying(progress = 0.75f), status) + } + + @Test + fun toStatus_withSuccessfulWork_reportsSuccess() { + val status = toStatus( + batch = batch(workRequestCount = 2, itemCount = 3), + workInfos = listOf( + workInfo(state = WorkInfo.State.SUCCEEDED, successfulCount = 2), + workInfo(state = WorkInfo.State.SUCCEEDED, successfulCount = 1), + ), + ) + + assertEquals( + MediaCopyBatchStatus.Finished( + copiedCount = 3, + failedCount = 0, + successful = true, + ), + status, + ) + } + + @Test + fun toStatus_withMixedResult_reportsCounts() { + val status = toStatus( + batch = batch(workRequestCount = 2, itemCount = 4), + workInfos = listOf( + workInfo(state = WorkInfo.State.SUCCEEDED, successfulCount = 2), + workInfo( + state = WorkInfo.State.FAILED, + successfulCount = 1, + failedCount = 1, + ), + ), + ) + + assertEquals( + MediaCopyBatchStatus.Finished( + copiedCount = 3, + failedCount = 1, + successful = false, + ), + status, + ) + } + + @Test + fun observe_whenWorkInfoQueryFails_emitsUnavailable() { + runTest { + val workManager = mockk() + every { workManager.getWorkInfosByTagFlow(BATCH_TAG) } returns flow { + throw IllegalStateException("query failed") + } + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + + val status = scheduler.observe(batch = batch()).first() + + assertEquals(MediaCopyBatchStatus.Unavailable, status) + } + } + + @Test + fun observe_whenCollectionIsCancelled_propagatesCancellation() { + runTest { + val workManager = mockk() + every { workManager.getWorkInfosByTagFlow(BATCH_TAG) } returns flow { + throw CancellationException("cancelled") + } + val scheduler = MediaCopySchedulerImpl(workManager = workManager) + + val exception = runCatching { + scheduler.observe(batch = batch()).first() + }.exceptionOrNull() + + assertEquals(CancellationException::class.java, exception?.javaClass) + } + } + + private fun operation( + result: SettableFuture, + ): Operation { + return mockk { + every { getResult() } returns result + } + } + + private fun copyRequests(count: Int): List { + return List(count) { index -> + MediaCopyRequest( + sourceUri = "content://media/$index".toUri(), + destinationPath = "Pictures/Test", + ) + } + } + + private fun toStatus( + batch: MediaCopyBatch, + workInfos: List, + ): MediaCopyBatchStatus { + val scheduler = MediaCopySchedulerImpl(workManager = mockk(relaxed = true)) + return scheduler.toStatus( + batch = batch, + workInfos = workInfos, + ) + } + + private fun batch( + workRequestCount: Int = 1, + itemCount: Int = 1, + ): MediaCopyBatch { + return MediaCopyBatch( + tag = BATCH_TAG, + workRequestCount = workRequestCount, + itemCount = itemCount, + ) + } + + private fun workInfo( + state: WorkInfo.State, + successfulCount: Int = 0, + failedCount: Int = 0, + progress: Int = 0, + ): WorkInfo { + return WorkInfo( + id = UUID.randomUUID(), + state = state, + tags = setOf(BATCH_TAG), + outputData = workDataOf( + MediaCopyWorker.MEDIA_COPY_SUCCESSFUL_COUNT_KEY to successfulCount, + MediaCopyWorker.MEDIA_COPY_FAILED_COUNT_KEY to failedCount, + ), + progress = workDataOf(MediaCopyWorker.MEDIA_COPY_PROGRESS_KEY to progress), + ) + } + + companion object { + private const val BATCH_TAG = "MediaCopyBatch_test" + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopyWorkerTest.kt b/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopyWorkerTest.kt new file mode 100644 index 0000000000..3a44811fd5 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/workers/MediaCopyWorkerTest.kt @@ -0,0 +1,194 @@ +package com.dot.gallery.core.workers + +import android.content.Context +import android.net.Uri +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import com.dot.gallery.feature_node.data.repository.MediaCopyRepository +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +internal class MediaCopyWorkerTest { + + @Test + fun doWork_whenEveryCopySucceeds_returnsCountsAndNotifiesMediaStore() { + runTest { + val repository = FakeMediaCopyRepository( + copyResults = listOf(destinationUri, destinationUri), + ) + val worker = mediaCopyWorker( + repository = repository, + uriCount = 2, + ) + + val result = worker.doWork() + val expectedOutput = mediaCopyOutput(successfulCount = 2, failedCount = 0) + + assertEquals(ListenableWorker.Result.success(expectedOutput), result) + assertEquals(1, repository.notificationCount.get()) + } + } + + @Test + fun doWork_whenOneCopyFails_returnsFailureCountsWithoutRetry() { + runTest { + val repository = FakeMediaCopyRepository( + copyResults = listOf(destinationUri, null), + ) + val worker = mediaCopyWorker( + repository = repository, + uriCount = 2, + ) + + val result = worker.doWork() + val expectedOutput = mediaCopyOutput(successfulCount = 1, failedCount = 1) + + assertEquals(ListenableWorker.Result.failure(expectedOutput), result) + assertEquals(1, repository.notificationCount.get()) + } + } + + @Test + fun doWork_whenEveryCopyFails_returnsFailureWithoutNotification() { + runTest { + val repository = FakeMediaCopyRepository(copyResults = listOf(null, null)) + val worker = mediaCopyWorker( + repository = repository, + uriCount = 2, + ) + + val result = worker.doWork() + val expectedOutput = mediaCopyOutput(successfulCount = 0, failedCount = 2) + + assertEquals(ListenableWorker.Result.failure(expectedOutput), result) + assertEquals(0, repository.notificationCount.get()) + } + } + + @Test + fun doWork_whenInputArraysHaveDifferentSizes_returnsTerminalFailure() { + runTest { + val repository = FakeMediaCopyRepository(copyResults = emptyList()) + val worker = mediaCopyWorker( + repository = repository, + uriCount = 2, + pathCount = 1, + ) + + val result = worker.doWork() + val expectedOutput = mediaCopyOutput(successfulCount = 0, failedCount = 2) + + assertEquals(ListenableWorker.Result.failure(expectedOutput), result) + assertEquals(0, repository.copyCount.get()) + } + } + + @Test + fun doWork_whenRepositoryIsCancelled_rethrowsCancellation() { + runTest { + val cancellationException = CancellationException("cancelled") + val repository = FakeMediaCopyRepository( + copyResults = emptyList(), + copyException = cancellationException, + ) + val worker = mediaCopyWorker( + repository = repository, + uriCount = 1, + ) + + val thrownException = try { + worker.doWork() + null + } catch (exception: CancellationException) { + exception + } + + assertEquals(cancellationException.message, thrownException?.message) + } + } + + private fun mediaCopyWorker( + repository: MediaCopyRepository, + uriCount: Int, + pathCount: Int = uriCount, + ): MediaCopyWorker { + val context = RuntimeEnvironment.getApplication().applicationContext + val inputData = workDataOf( + "uris" to Array(uriCount) { index -> "content://media/source/$index" }, + "paths" to Array(pathCount) { "Pictures/Test" }, + ) + val workerFactory = object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker { + return MediaCopyWorker( + mediaCopyRepository = repository, + appContext = appContext, + params = workerParameters, + ) + } + } + + return TestListenableWorkerBuilder( + context = context, + inputData = inputData, + ).setWorkerFactory(workerFactory) + .build() + } + + private fun mediaCopyOutput(successfulCount: Int, failedCount: Int): Data { + return workDataOf( + MediaCopyWorker.MEDIA_COPY_SUCCESSFUL_COUNT_KEY to successfulCount, + MediaCopyWorker.MEDIA_COPY_FAILED_COUNT_KEY to failedCount, + ) + } + + private class FakeMediaCopyRepository( + copyResults: List, + private val copyException: CancellationException? = null, + ) : MediaCopyRepository { + + private val remainingResults = ArrayDeque(copyResults) + + val copyCount = AtomicInteger(0) + val notificationCount = AtomicInteger(0) + + override suspend fun getMediaSize(uri: Uri): Long { + return 1L + } + + override suspend fun copyMedia( + sourceUri: Uri, + destinationPath: String, + onBytesCopied: suspend (Int) -> Unit, + ): Uri? { + copyCount.incrementAndGet() + copyException?.let { exception -> + throw exception + } + onBytesCopied(1) + return remainingResults.removeFirst() + } + + override suspend fun notifyMediaChanged() { + notificationCount.incrementAndGet() + } + } + + companion object { + private val destinationUri = Uri.parse("content://media/destination/1") + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorkerTest.kt b/app/src/test/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorkerTest.kt new file mode 100644 index 0000000000..a2deb0db6e --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/core/workers/SearchIndexerUpdaterWorkerTest.kt @@ -0,0 +1,478 @@ +package com.dot.gallery.core.workers + +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.core.graphics.createBitmap +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import com.dot.gallery.core.ml.ImageEmbeddingGenerator +import com.dot.gallery.core.ml.ImageEmbeddingSession +import com.dot.gallery.core.ml.ModelStatus +import com.dot.gallery.core.sandbox.MediaPreviewDecoder +import com.dot.gallery.feature_node.data.model.AiMediaAnalysisPreferences +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.ImageEmbeddingStamp +import com.dot.gallery.feature_node.data.model.Media.UriMedia +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.data.repository.MediaRepository +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Config(application = Application::class) +@RunWith(RobolectricTestRunner::class) +internal class SearchIndexerUpdaterWorkerTest { + + @Test + fun disabledAnalysis_skipsMediaAndModelAccess() { + runTest { + val dependencies = dependencies(analysisEnabled = false) + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 0), result) + coVerify(exactly = 0) { + dependencies.mediaRepository.getCompleteMediaPage( + afterId = any(), + limit = any(), + ) + } + verify(exactly = 0) { dependencies.embeddingGenerator.status } + coVerify(exactly = 0) { + dependencies.previewDecoder.decode(uri = any(), mimeType = any(), isVideo = any()) + } + } + } + + @Test + fun mediaDelta_invalidatesChangedDataAndIndexesEachChangedItemOnce() { + runTest { + val unchangedMedia = media(id = 1L, timestamp = 10L) + val changedMedia = media(id = 2L, timestamp = 20L) + val newMedia = media(id = 3L, timestamp = 30L) + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = listOf(unchangedMedia, changedMedia, newMedia), + ) + stubStampPages( + dependencies = dependencies, + stamps = listOf( + stamp(id = 1L, date = 10L), + stamp(id = 2L, date = 15L), + stamp(id = 4L, date = 40L), + ), + ) + val changedBitmap = createBitmap(width = 8, height = 8) + val newBitmap = createBitmap(width = 8, height = 8) + coEvery { + dependencies.previewDecoder.decode( + uri = changedMedia.uri, + mimeType = changedMedia.mimeType, + isVideo = false, + ) + } returns changedBitmap + coEvery { + dependencies.previewDecoder.decode( + uri = newMedia.uri, + mimeType = newMedia.mimeType, + isVideo = false, + ) + } returns newBitmap + every { dependencies.embeddingSession.generate(bitmap = changedBitmap) } returns floatArrayOf(2f) + every { dependencies.embeddingSession.generate(bitmap = newBitmap) } returns floatArrayOf(3f) + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 3), result) + coVerify(exactly = 1) { + dependencies.analysisRepository.removeMediaData(mediaIds = setOf(4L)) + dependencies.analysisRepository.invalidateGeneratedData(mediaIds = setOf(2L)) + } + coVerify(exactly = 2) { + dependencies.previewDecoder.decode(uri = any(), mimeType = any(), isVideo = false) + } + coVerify(exactly = 1) { + dependencies.mediaRepository.addImageEmbedding( + imageEmbedding = match { record -> + record.id == 2L && record.date == 20L && + record.embedding.contentEquals(floatArrayOf(2f)) + }, + ) + dependencies.mediaRepository.addImageEmbedding( + imageEmbedding = match { record -> + record.id == 3L && record.date == 30L && + record.embedding.contentEquals(floatArrayOf(3f)) + }, + ) + } + verify(exactly = 1) { dependencies.embeddingGenerator.openSession() } + verify(exactly = 1) { dependencies.embeddingSession.close() } + assertTrue(changedBitmap.isRecycled) + assertTrue(newBitmap.isRecycled) + } + } + + @Test + fun initialLargeLibrary_doesNotInvalidateIdsWithoutExistingGeneratedData() { + runTest { + val media = (1L..LARGE_LIBRARY_SIZE.toLong()).map { mediaId -> + media(id = mediaId, timestamp = mediaId) + } + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = media, + ) + stubStampPages(dependencies = dependencies, stamps = emptyList()) + coEvery { + dependencies.previewDecoder.decode(uri = any(), mimeType = any(), isVideo = any()) + } returns null + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = LARGE_LIBRARY_SIZE), result) + verify(exactly = 0) { dependencies.mediaRepository.getCompleteMedia() } + coVerify(exactly = 0) { + dependencies.analysisRepository.invalidateGeneratedData(mediaIds = any()) + } + } + } + + @Test + fun missingManualMapping_isRemovedThroughPagedReconciliation() { + runTest { + val existingMedia = media(id = 1L, timestamp = 10L) + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = listOf(existingMedia), + ) + coEvery { + dependencies.analysisRepository.getClassifiedMediaIdPage( + afterId = Long.MIN_VALUE, + limit = EMBEDDING_STAMP_PAGE_SIZE, + ) + } returns listOf(1L, 2L) + coEvery { + dependencies.analysisRepository.getClassifiedMediaIdPage( + afterId = 2L, + limit = EMBEDDING_STAMP_PAGE_SIZE, + ) + } returns emptyList() + stubStampPages( + dependencies = dependencies, + stamps = listOf(stamp(id = 1L, date = 10L)), + ) + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 1), result) + coVerify(exactly = 1) { + dependencies.analysisRepository.removeMediaData(mediaIds = setOf(2L)) + } + coVerify(exactly = 0) { + dependencies.previewDecoder.decode(uri = any(), mimeType = any(), isVideo = any()) + } + } + } + + @Test + fun unchangedLibrary_doesNotOpenEmbeddingSession() { + runTest { + val unchangedMedia = media(id = 1L, timestamp = 10L) + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = listOf(unchangedMedia), + ) + stubStampPages( + dependencies = dependencies, + stamps = listOf(stamp(id = 1L, date = 10L)), + ) + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 0), result) + verify(exactly = 0) { dependencies.embeddingGenerator.openSession() } + coVerify(exactly = 0) { + dependencies.previewDecoder.decode(uri = any(), mimeType = any(), isVideo = any()) + } + } + } + + @Test + fun failedDecode_isRetriedByTheNextIndexingRun() { + runTest { + val media = media(id = 1L, timestamp = 10L) + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = listOf(media), + ) + stubStampPages(dependencies = dependencies, stamps = emptyList()) + coEvery { + dependencies.previewDecoder.decode( + uri = media.uri, + mimeType = media.mimeType, + isVideo = false, + ) + } returns null + + val firstResult = buildWorker(dependencies = dependencies).doWork() + val secondResult = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 1), firstResult) + assertEquals(indexingSuccess(changedCount = 1), secondResult) + coVerify(exactly = 2) { + dependencies.previewDecoder.decode( + uri = media.uri, + mimeType = media.mimeType, + isVideo = false, + ) + } + coVerify(exactly = 0) { + dependencies.mediaRepository.addImageEmbedding(imageEmbedding = any()) + } + verify(exactly = 0) { dependencies.embeddingGenerator.openSession() } + } + } + + @Test + fun consentRevokedDuringInference_discardsTheGeneratedEmbedding() { + runTest { + val media = media(id = 1L, timestamp = 10L) + val dependencies = dependencies() + var analysisEnabled = true + coEvery { dependencies.analysisRepository.getPreferences() } answers { + preferences(analysisEnabled = analysisEnabled) + } + stubMediaPages( + dependencies = dependencies, + media = listOf(media), + ) + stubStampPages(dependencies = dependencies, stamps = emptyList()) + val bitmap = createBitmap(width = 8, height = 8) + coEvery { + dependencies.previewDecoder.decode( + uri = media.uri, + mimeType = media.mimeType, + isVideo = false, + ) + } returns bitmap + every { dependencies.embeddingSession.generate(bitmap = bitmap) } answers { + analysisEnabled = false + floatArrayOf(1f) + } + + val result = buildWorker(dependencies = dependencies).doWork() + + assertEquals(indexingSuccess(changedCount = 1), result) + coVerify(exactly = 0) { + dependencies.mediaRepository.addImageEmbedding(imageEmbedding = any()) + } + assertTrue(bitmap.isRecycled) + } + } + + @Test + fun cancellationDuringDecode_isPropagated() { + runTest { + val media = media(id = 1L, timestamp = 10L) + val dependencies = dependencies() + stubMediaPages( + dependencies = dependencies, + media = listOf(media), + ) + stubStampPages(dependencies = dependencies, stamps = emptyList()) + coEvery { + dependencies.previewDecoder.decode( + uri = media.uri, + mimeType = media.mimeType, + isVideo = false, + ) + } throws CancellationException("cancelled") + + val thrownException = runCatching { + buildWorker(dependencies = dependencies).doWork() + }.exceptionOrNull() + + assertTrue(thrownException is CancellationException) + assertEquals("cancelled", thrownException?.message) + coVerify(exactly = 0) { + dependencies.mediaRepository.addImageEmbedding(imageEmbedding = any()) + } + } + } + + private fun buildWorker(dependencies: Dependencies): SearchIndexerUpdaterWorker { + val context: Context = RuntimeEnvironment.getApplication() + return TestListenableWorkerBuilder(context = context) + .setWorkerFactory( + object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker { + return SearchIndexerUpdaterWorker( + repository = dependencies.mediaRepository, + analysisRepository = dependencies.analysisRepository, + embeddingGenerator = dependencies.embeddingGenerator, + previewDecoder = dependencies.previewDecoder, + appContext = appContext, + workerParams = workerParameters, + ) + } + }, + ) + .build() + } + + private fun dependencies(analysisEnabled: Boolean = true): Dependencies { + val mediaRepository = mockk() + val analysisRepository = mockk() + val embeddingGenerator = mockk() + val embeddingSession = mockk() + val previewDecoder = mockk() + coEvery { analysisRepository.getPreferences() } returns preferences( + analysisEnabled = analysisEnabled, + ) + coEvery { + analysisRepository.getClassifiedMediaIdPage(afterId = any(), limit = any()) + } returns emptyList() + coJustRun { analysisRepository.removeMediaData(mediaIds = any()) } + coJustRun { analysisRepository.invalidateGeneratedData(mediaIds = any()) } + coJustRun { mediaRepository.addImageEmbedding(imageEmbedding = any()) } + every { embeddingGenerator.status } returns MutableStateFlow(ModelStatus.READY) + every { embeddingGenerator.openSession() } returns embeddingSession + every { embeddingSession.close() } just Runs + return Dependencies( + mediaRepository = mediaRepository, + analysisRepository = analysisRepository, + embeddingGenerator = embeddingGenerator, + embeddingSession = embeddingSession, + previewDecoder = previewDecoder, + ) + } + + private fun media(id: Long, timestamp: Long): UriMedia { + return UriMedia( + id = id, + label = "image_$id.png", + uri = Uri.parse("content://media/external/images/media/$id"), + path = "/storage/emulated/0/Pictures/image_$id.png", + relativePath = "Pictures", + albumID = 1L, + albumLabel = "Pictures", + timestamp = timestamp, + fullDate = "", + mimeType = "image/png", + favorite = 0, + trashed = 0, + size = 1L, + ) + } + + private fun stamp(id: Long, date: Long): ImageEmbeddingStamp { + return ImageEmbeddingStamp( + id = id, + date = date, + ) + } + + private fun stubStampPages( + dependencies: Dependencies, + stamps: List, + ) { + var afterId = Long.MIN_VALUE + stamps.chunked(EMBEDDING_STAMP_PAGE_SIZE).forEach { stampPage -> + val pageAfterId = afterId + coEvery { + dependencies.mediaRepository.getImageEmbeddingStampPage( + afterId = pageAfterId, + limit = EMBEDDING_STAMP_PAGE_SIZE, + ) + } returns stampPage + afterId = stampPage.last().id + } + coEvery { + dependencies.mediaRepository.getImageEmbeddingStampPage( + afterId = afterId, + limit = EMBEDDING_STAMP_PAGE_SIZE, + ) + } returns emptyList() + } + + private fun stubMediaPages( + dependencies: Dependencies, + media: List, + ) { + var afterId = Long.MIN_VALUE + media.chunked(MEDIA_PAGE_SIZE).forEach { mediaPage -> + val pageAfterId = afterId + coEvery { + dependencies.mediaRepository.getCompleteMediaPage( + afterId = pageAfterId, + limit = MEDIA_PAGE_SIZE, + ) + } returns mediaPage + afterId = mediaPage.last().id + } + coEvery { + dependencies.mediaRepository.getCompleteMediaPage( + afterId = afterId, + limit = MEDIA_PAGE_SIZE, + ) + } returns emptyList() + } + + private fun indexingSuccess(changedCount: Int): ListenableWorker.Result { + return ListenableWorker.Result.success( + workDataOf(SearchIndexerUpdaterWorker.KEY_CHANGED_COUNT to changedCount), + ) + } + + private fun preferences(analysisEnabled: Boolean): AiMediaAnalysisPreferences { + return AiMediaAnalysisPreferences( + analysisEnabled = analysisEnabled, + categoryClassificationEnabled = true, + analysisCleanupPending = false, + categoryCleanupPending = false, + ) + } + + private companion object { + private const val EMBEDDING_STAMP_PAGE_SIZE = 900 + private const val LARGE_LIBRARY_SIZE = 1_001 + private const val MEDIA_PAGE_SIZE = 256 + } + + private data class Dependencies( + val mediaRepository: MediaRepository, + val analysisRepository: AiMediaAnalysisRepository, + val embeddingGenerator: ImageEmbeddingGenerator, + val embeddingSession: ImageEmbeddingSession, + val previewDecoder: MediaPreviewDecoder, + ) +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunkingTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunkingTest.kt new file mode 100644 index 0000000000..f03c905804 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/data_source/QueryIdChunkingTest.kt @@ -0,0 +1,38 @@ +package com.dot.gallery.feature_node.data.data_source + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class QueryIdChunkingTest { + + @Test + fun forEachIdChunk_limitsEveryQueryArgumentSet() { + runTest { + val processedChunks = mutableListOf>() + + forEachIdChunk(ids = (1L..2_001L).toSet()) { mediaIds -> + processedChunks += mediaIds + } + + assertEquals(listOf(900, 900, 201), processedChunks.map { chunk -> chunk.size }) + assertTrue(processedChunks.flatten().toSet() == (1L..2_001L).toSet()) + } + } + + @Test + fun flatMapIdChunks_limitsEveryQueryArgumentSetAndConcatenatesRows() { + runTest { + val queriedChunks = mutableListOf>() + + val rows = flatMapIdChunks(ids = (1L..2_001L).toSet()) { mediaIds -> + queriedChunks += mediaIds + mediaIds.map { id -> id * 2 } + } + + assertEquals(listOf(900, 900, 201), queriedChunks.map { chunk -> chunk.size }) + assertEquals((1L..2_001L).map { id -> id * 2 }.toSet(), rows.toSet()) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ComponentCallerExternalCropUriPermissionCheckerTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ComponentCallerExternalCropUriPermissionCheckerTest.kt new file mode 100644 index 0000000000..64ceb336db --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ComponentCallerExternalCropUriPermissionCheckerTest.kt @@ -0,0 +1,428 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import android.app.ComponentCaller +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Environment +import android.os.Process +import android.provider.MediaStore +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ComponentCallerExternalCropUriPermissionCheckerTest { + + @Test + fun canWriteContentUri_acceptsOutputOwnedByCaller() { + val fixture = permissionChecker(resolvedType = "image/jpeg", owner = CALLER_PACKAGE) + + assertTrue( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_acceptsOutputOwnedByUs() { + val fixture = permissionChecker(resolvedType = "image/jpeg", owner = OUR_PACKAGE) + + assertTrue( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * The confused deputy guard: we hold write access to all of shared media, the caller does not. + * Cropping into a row owned by someone else would let the caller overwrite media it cannot + * touch itself, and our crop ui only ever shows the source, so the user cannot catch it. + */ + @Test + fun canWriteContentUri_rejectsOutputOwnedByThirdParty() { + val fixture = permissionChecker(resolvedType = "image/jpeg", owner = "com.example.victim") + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsOutputWithoutRecordedOwner() { + val fixture = permissionChecker(resolvedType = "image/jpeg", owner = null) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsOutputWithNoRow() { + val fixture = permissionChecker( + resolvedType = "image/jpeg", + owner = null, + ownerRowPresent = false, + ) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * A caller sharing a uid with other packages owns everything any of them owns. + */ + @Test + fun canWriteContentUri_acceptsOutputOwnedByAnotherPackageInTheCallerUid() { + val fixture = permissionChecker( + resolvedType = "image/jpeg", + owner = "com.example.sibling", + callerPackages = arrayOf(CALLER_PACKAGE, "com.example.sibling"), + ) + + assertTrue( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsUnreachableMediaStoreUri() { + val fixture = permissionChecker(resolvedType = null) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsForeignAuthorityWithoutGrant() { + val fixture = permissionChecker(resolvedType = "image/jpeg") + + assertFalse( + fixture.checker.canWriteContentUri( + uri = FOREIGN_OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * AvatarPicker's shape: one uri from its own [androidx.core.content.FileProvider], passed as both + * the intent data and [MediaStore.EXTRA_OUTPUT] with + * [android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION]. That puts it in the launch grant set, + * so the caller has proven it can write the output itself and we are not acting as its deputy. + */ + @Test + fun canWriteContentUri_acceptsNonMediaStoreOutputWithWriteGrant() { + val fixture = permissionChecker(grantResult = PackageManager.PERMISSION_GRANTED) + + assertTrue( + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsNonMediaStoreOutputWithoutWriteGrant() { + val fixture = permissionChecker(grantResult = PackageManager.PERMISSION_DENIED) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * The extra-only shape: the caller named an output uri it never granted, so the platform throws + * rather than answering. Rejecting is the correct answer — the caller proved nothing. + */ + @Test + fun canWriteContentUri_rejectsNonMediaStoreOutputOutsideLaunchGrantSet() { + val fixture = permissionChecker( + grantException = IllegalArgumentException("uri not in the launch grant set"), + ) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + @Test + fun canWriteContentUri_rejectsNonMediaStoreOutputWhenGrantCheckThrowsSecurityException() { + val fixture = permissionChecker( + grantException = SecurityException("not accessible"), + ) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * A write grant on a media uri must not buy access to a row the caller does not own. MediaStore + * ownership is the stricter rule and stays the only one consulted there. + */ + @Test + fun canWriteContentUri_rejectsThirdPartyMediaStoreOwnerEvenWithWriteGrant() { + val fixture = permissionChecker( + resolvedType = "image/jpeg", + owner = "com.example.victim", + grantResult = PackageManager.PERMISSION_GRANTED, + ) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * Writing the output is not reading it: a caller holding only a read grant must not be able to + * aim our writes. + */ + @Test + fun canWriteContentUri_checksTheWriteFlagNotTheReadFlag() { + val fixture = permissionChecker(grantResult = PackageManager.PERMISSION_GRANTED) + + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ) + + verify(exactly = 1) { + fixture.caller.checkContentUriPermission( + AVATAR_PICKER_OUTPUT_URI, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + + @Test + fun canWriteContentUri_returnsFalseWhenPlatformThrowsSecurityException() { + val fixture = permissionChecker( + resolveException = SecurityException("not accessible"), + ) + + assertFalse( + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ), + ) + } + + /** + * A legacy MediaStore output arrives in [MediaStore.EXTRA_OUTPUT] alone, so it is outside the + * launch grant set and [ComponentCaller.checkContentUriPermission] would throw — routing media + * uris through it would reject every legacy `ACTION_CROP` request. Ownership answers there + * instead; pin that the launch grant api is never consulted for media. + */ + @Test + fun canWriteContentUri_neverUsesLaunchGrantApiForMediaStoreOutput() { + val fixture = permissionChecker(resolvedType = "image/jpeg") + + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ) + + verify(exactly = 0) { fixture.caller.checkContentUriPermission(any(), any()) } + } + + /** + * `"w"` truncates the target on some providers, so probing writability by opening the output + * would destroy it before the user confirms the crop. Neither branch may do it. + */ + @Test + fun canWriteContentUri_neverOpensTheOutput() { + val fixture = permissionChecker( + resolvedType = "image/jpeg", + grantResult = PackageManager.PERMISSION_GRANTED, + ) + + fixture.checker.canWriteContentUri( + uri = OUTPUT_URI, + caller = fixture.caller, + ) + fixture.checker.canWriteContentUri( + uri = AVATAR_PICKER_OUTPUT_URI, + caller = fixture.caller, + ) + + verify(exactly = 0) { fixture.contentResolver.openFileDescriptor(any(), any()) } + verify(exactly = 0) { fixture.contentResolver.openOutputStream(any()) } + } + + @Test + fun canReadFileUri_acceptsSharedImageDirectories() { + val checker = ComponentCallerExternalCropUriPermissionChecker( + context = mockk(relaxed = true), + packageManager = mockk(relaxed = true), + ) + val caller = currentProcessCaller() + val picturesFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "image.jpg", + ) + val dcimFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + "Camera/image.jpg", + ) + + assertTrue( + checker.canReadFileUri( + uri = Uri.fromFile(picturesFile), + caller = caller, + ), + ) + assertTrue( + checker.canReadFileUri( + uri = Uri.fromFile(dcimFile), + caller = caller, + ), + ) + } + + @Test + fun canReadFileUri_rejectsPrivateAndEscapedPaths() { + val checker = ComponentCallerExternalCropUriPermissionChecker( + context = mockk(relaxed = true), + packageManager = mockk(relaxed = true), + ) + val caller = currentProcessCaller() + val privateUri = Uri.parse("file:///data/data/com.dot.gallery/files/image.jpg") + val escapedUri = Uri.parse( + "file:///storage/emulated/0/Pictures/../Android/data/com.example/files/image.jpg", + ) + + assertFalse( + checker.canReadFileUri( + uri = privateUri, + caller = caller, + ), + ) + assertFalse( + checker.canReadFileUri( + uri = escapedUri, + caller = caller, + ), + ) + } + + private fun permissionChecker( + resolvedType: String? = null, + resolveException: RuntimeException? = null, + owner: String? = CALLER_PACKAGE, + ownerRowPresent: Boolean = true, + callerPackages: Array = arrayOf(CALLER_PACKAGE), + grantResult: Int = PackageManager.PERMISSION_DENIED, + grantException: RuntimeException? = null, + ): PermissionCheckerFixture { + val callerUid = Process.myUid() + 1 + val caller = mockk() + every { caller.uid } returns callerUid + every { caller.getPackage() } returns callerPackages.firstOrNull() + + val grantCheck = every { caller.checkContentUriPermission(any(), any()) } + if (grantException == null) { + grantCheck returns grantResult + } else { + grantCheck throws grantException + } + + val contentResolver = mockk(relaxed = true) + val typeCheck = every { contentResolver.getType(any()) } + if (resolveException == null) { + typeCheck returns resolvedType + } else { + typeCheck throws resolveException + } + every { contentResolver.query(any(), any(), any(), any(), any()) } returns + ownerCursor(owner = owner, rowPresent = ownerRowPresent) + + val context = mockk() + every { context.contentResolver } returns contentResolver + every { context.packageName } returns OUR_PACKAGE + + val packageManager = mockk(relaxed = true) + every { packageManager.getPackagesForUid(callerUid) } returns callerPackages + + return PermissionCheckerFixture( + checker = ComponentCallerExternalCropUriPermissionChecker( + context = context, + packageManager = packageManager, + ), + caller = caller, + contentResolver = contentResolver, + ) + } + + private fun ownerCursor(owner: String?, rowPresent: Boolean): Cursor { + return MatrixCursor(arrayOf(MediaStore.MediaColumns.OWNER_PACKAGE_NAME)).apply { + if (rowPresent) addRow(arrayOf(owner)) + } + } + + private fun currentProcessCaller(): ComponentCaller { + return mockk().apply { + every { uid } returns Process.myUid() + } + } + + private data class PermissionCheckerFixture( + val checker: ComponentCallerExternalCropUriPermissionChecker, + val caller: ComponentCaller, + val contentResolver: ContentResolver, + ) + + private companion object { + const val CALLER_PACKAGE = "com.example.caller" + const val OUR_PACKAGE = "com.dot.gallery" + val OUTPUT_URI: Uri = Uri.parse("content://media/external/images/media/42") + val AVATAR_PICKER_OUTPUT_URI: Uri = + Uri.parse("content://com.android.avatarpicker.tempprovider/output.png") + val FOREIGN_OUTPUT_URI: Uri = Uri.parse("content://com.example.provider/output") + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParserTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParserTest.kt new file mode 100644 index 0000000000..0a49958196 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropIntentParserTest.kt @@ -0,0 +1,292 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import android.app.ComponentCaller +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExternalCropIntentParserTest { + + @Test + fun parse_acceptsGrantedContentSource() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = cropIntent(sourceUri = sourceUri), + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNotNull(request) + assertEquals(sourceUri, request?.sourceUri) + } + + @Test + fun parse_rejectsContentSourceWithoutCallerReadPermission() { + val request = parse( + intent = cropIntent( + sourceUri = Uri.parse("content://source/image"), + ), + uriPermissionChecker = uriPermissionChecker(), + ) + + assertNull(request) + } + + @Test + fun parse_rejectsUnsupportedSourceScheme() { + val request = parse( + intent = cropIntent( + sourceUri = Uri.parse("https://example.test/image.jpg"), + ), + uriPermissionChecker = uriPermissionChecker(), + ) + + assertNull(request) + } + + @Test + fun parse_acceptsAllowedFileSource() { + val sourceUri = Uri.parse("file:///storage/emulated/0/Pictures/image.jpg") + val request = parse( + intent = cropIntent(sourceUri = sourceUri), + uriPermissionChecker = uriPermissionChecker( + readableFileUris = setOf(sourceUri), + ), + ) + + assertNotNull(request) + assertEquals(sourceUri, request?.sourceUri) + } + + @Test + fun parse_rejectsFileSourceWithoutCallerPermission() { + val request = parse( + intent = cropIntent( + sourceUri = Uri.parse("file:///storage/emulated/0/Pictures/image.jpg"), + ), + uriPermissionChecker = uriPermissionChecker(), + ) + + assertNull(request) + } + + @Test + fun parse_acceptsGrantedContentOutput() { + val sourceUri = Uri.parse("content://source/image") + val outputUri = Uri.parse("content://output/image") + val request = parse( + intent = cropIntent( + sourceUri = sourceUri, + outputUri = outputUri, + ), + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + writableContentUris = setOf(outputUri), + ), + ) + + assertNotNull(request) + assertEquals(outputUri, request?.outputUri) + } + + /** + * AvatarPicker passes one uri from its own provider as both the source and the output, so the + * crop is read from and written back to the same file. Nothing else pins that shape surviving the + * parser. + */ + @Test + fun parse_acceptsAvatarPickerShapeWhereOutputIsAlsoTheSource() { + val uri = Uri.parse("content://com.android.avatarpicker.tempprovider/output.png") + val request = parse( + intent = cropIntent( + sourceUri = uri, + outputUri = uri, + ), + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(uri), + writableContentUris = setOf(uri), + ), + ) + + assertNotNull(request) + assertEquals(uri, request?.sourceUri) + assertEquals(uri, request?.outputUri) + } + + @Test + fun parse_rejectsContentOutputWithoutCallerWritePermission() { + val sourceUri = Uri.parse("content://source/image") + val outputUri = Uri.parse("content://output/image") + val request = parse( + intent = cropIntent( + sourceUri = sourceUri, + outputUri = outputUri, + ), + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNull(request) + } + + @Test + fun parse_rejectsFileOutput() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = cropIntent( + sourceUri = sourceUri, + outputUri = Uri.parse("file:///storage/emulated/0/Pictures/output.jpg"), + ), + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNull(request) + } + + @Test + fun parse_withoutOutputDoesNotForceFallbackOutput() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = cropIntent(sourceUri = sourceUri).apply { + putExtra("return-data", true) + }, + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNotNull(request) + assertNull(request?.outputUri) + } + + @Test + fun parse_rejectsWrongAction() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = Intent(Intent.ACTION_VIEW).apply { + data = sourceUri + }, + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNull(request) + } + + @Test + fun parse_ignoresShowWhenLockedExtra() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = cropIntent(sourceUri = sourceUri).apply { + putExtra("showWhenLocked", true) + }, + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNotNull(request) + } + + @Test + fun parse_rejectsNonUriOutputExtra() { + val sourceUri = Uri.parse("content://source/image") + val request = parse( + intent = cropIntent(sourceUri = sourceUri).apply { + putExtra(MediaStore.EXTRA_OUTPUT, "not a uri") + }, + uriPermissionChecker = uriPermissionChecker( + readableContentUris = setOf(sourceUri), + ), + ) + + assertNull(request) + } + + @Test + fun parse_doesNotSwallowRuntimeFailure() { + val sourceUri = Uri.parse("content://source/image") + val exception = RuntimeException("parser bug") + val thrownException = assertThrows( + RuntimeException::class.java, + ) { + parse( + intent = cropIntent(sourceUri = sourceUri), + uriPermissionChecker = throwingUriPermissionChecker( + throwable = exception, + ), + ) + } + + assertSame( + exception, + thrownException, + ) + } + + private fun cropIntent( + sourceUri: Uri, + outputUri: Uri? = null, + ): Intent { + return Intent(ExternalCropIntentParser.ACTION_CROP).apply { + data = sourceUri + outputUri?.let { uri -> + putExtra(MediaStore.EXTRA_OUTPUT, uri) + } + } + } + + private fun parse( + intent: Intent, + uriPermissionChecker: ExternalCropUriPermissionChecker, + ): ExternalCropRequest? { + val parser = ExternalCropIntentParserImpl(uriPermissionChecker = uriPermissionChecker) + return parser.parse( + intent = intent, + caller = mockk(relaxed = true), + ) + } + + private fun uriPermissionChecker( + readableContentUris: Set = emptySet(), + writableContentUris: Set = emptySet(), + readableFileUris: Set = emptySet(), + ): ExternalCropUriPermissionChecker { + return mockk().apply { + every { canReadContentUri(uri = any(), caller = any()) } answers { + firstArg() in readableContentUris + } + every { canWriteContentUri(uri = any(), caller = any()) } answers { + firstArg() in writableContentUris + } + every { canReadFileUri(uri = any(), caller = any()) } answers { + firstArg() in readableFileUris + } + } + } + + private fun throwingUriPermissionChecker( + throwable: RuntimeException, + ): ExternalCropUriPermissionChecker { + return mockk().apply { + every { canReadContentUri(uri = any(), caller = any()) } throws throwable + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizingTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizingTest.kt new file mode 100644 index 0000000000..b6fa46413c --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/externalcrop/ExternalCropSizingTest.kt @@ -0,0 +1,135 @@ +package com.dot.gallery.feature_node.data.externalcrop + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExternalCropSizingTest { + + @Test + fun resolveCropOutputSize_returnsExactOutputWhenScaleUpIsAllowed() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 300, + outputY = 300, + scale = true, + scaleUpIfNeeded = true, + ) + + assertEquals(CropSize(width = 300, height = 300), size) + } + + @Test + fun resolveCropOutputSize_clampsOversizedScaleUpRequest() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 100_000, + outputY = 100_000, + scale = true, + scaleUpIfNeeded = true, + ) + + assertEquals(CropSize(width = 4096, height = 4096), size) + } + + @Test + fun resolveCropOutputSize_preservesAspectRatioWhenClampingWideRequest() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 100_000, + outputY = 1_000, + scale = true, + scaleUpIfNeeded = true, + ) + + assertEquals(CropSize(width = 8192, height = 82), size) + } + + @Test + fun resolveCropOutputSize_doesNotUpscaleWhenScaleUpIsDisabled() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 300, + outputY = 300, + scale = true, + scaleUpIfNeeded = false, + ) + + assertEquals(CropSize(width = 100, height = 100), size) + } + + @Test + fun resolveCropOutputSize_downscalesToFitRequestedBounds() { + val size = resolveCropOutputSize( + cropWidth = 800, + cropHeight = 400, + outputX = 200, + outputY = 200, + scale = true, + scaleUpIfNeeded = false, + ) + + assertEquals(CropSize(width = 200, height = 100), size) + } + + @Test + fun resolveCropOutputSize_returnsExactOutputCanvasWhenScaleIsDisabled() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 300, + outputY = 300, + scale = false, + scaleUpIfNeeded = false, + ) + + assertEquals(CropSize(width = 300, height = 300), size) + } + + @Test + fun resolveCropOutputSize_clampsOversizedOutputCanvasWhenScaleIsDisabled() { + val size = resolveCropOutputSize( + cropWidth = 100, + cropHeight = 100, + outputX = 100_000, + outputY = 100_000, + scale = false, + scaleUpIfNeeded = false, + ) + + assertEquals(CropSize(width = 4096, height = 4096), size) + } + + @Test + fun resolveCropSourceDecodeSize_clampsOversizedSquareSource() { + val size = resolveCropSourceDecodeSize( + sourceWidth = 100_000, + sourceHeight = 100_000, + ) + + assertEquals(CropSize(width = 4096, height = 4096), size) + } + + @Test + fun resolveCropSourceDecodeSize_preservesAspectRatioWhenClampingWideSource() { + val size = resolveCropSourceDecodeSize( + sourceWidth = 100_000, + sourceHeight = 1_000, + ) + + assertEquals(CropSize(width = 8192, height = 82), size) + } + + @Test + fun resolveIntentBitmapSize_halvesUntilBelowBinderBudget() { + val size = resolveIntentBitmapSize( + width = 1000, + height = 1000, + ) + + assertEquals(CropSize(width = 250, height = 250), size) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequestTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequestTest.kt new file mode 100644 index 0000000000..d462a30672 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/model/editor/crop/ExternalCropRequestTest.kt @@ -0,0 +1,152 @@ +package com.dot.gallery.feature_node.data.model.editor.crop + +import android.net.Uri +import com.dot.gallery.feature_node.data.model.editor.SaveFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(sdk = [36]) +@RunWith(RobolectricTestRunner::class) +class ExternalCropRequestTest { + + @Test + fun aspectRatio_prefersExplicitAspectOverOutputSize() { + val request = externalCropRequest( + aspectX = 1, + aspectY = 1, + outputX = 16, + outputY = 9, + ) + + assertEquals(1f, request.aspectRatio) + } + + @Test + fun aspectRatio_usesOutputSizeWhenAspectIsMissing() { + val request = externalCropRequest( + aspectX = 0, + aspectY = 0, + outputX = 16, + outputY = 9, + ) + + assertEquals(16f / 9f, request.aspectRatio) + } + + @Test + fun aspectRatio_returnsNullWithoutAspectOrOutputSize() { + val request = externalCropRequest( + aspectX = 0, + aspectY = 0, + outputX = 0, + outputY = 0, + ) + + assertNull(request.aspectRatio) + } + + @Test + fun saveFormat_mapsPngAndGifToPng() { + assertEquals( + SaveFormat.Png, + externalCropRequest(outputFormat = "PNG").saveFormat, + ) + assertEquals( + SaveFormat.Png, + externalCropRequest(outputFormat = "image/gif").saveFormat, + ) + assertEquals( + SaveFormat.Png, + externalCropRequest(outputFormat = "avatar.png").saveFormat, + ) + assertEquals( + SaveFormat.Png, + externalCropRequest(outputFormat = "image/x-png").saveFormat, + ) + } + + @Test + fun saveFormat_mapsWebpToWebpLossy() { + assertEquals( + SaveFormat.WebpLossy, + externalCropRequest(outputFormat = "WEBP").saveFormat, + ) + assertEquals( + SaveFormat.WebpLossy, + externalCropRequest(outputFormat = "image/webp").saveFormat, + ) + assertEquals( + SaveFormat.WebpLossy, + externalCropRequest(outputFormat = "avatar.webp").saveFormat, + ) + assertEquals( + SaveFormat.WebpLossy, + externalCropRequest(outputFormat = "image/webp; charset=utf-8").saveFormat, + ) + } + + @Test + fun saveFormat_mapsWebpLosslessExplicitly() { + assertEquals( + SaveFormat.WebpLossless, + externalCropRequest(outputFormat = "WEBP_LOSSLESS").saveFormat, + ) + assertEquals( + SaveFormat.WebpLossless, + externalCropRequest(outputFormat = "Bitmap.CompressFormat.WEBP_LOSSLESS").saveFormat, + ) + } + + @Test + fun saveFormat_mapsJpegAndJpgToJpeg() { + assertEquals( + SaveFormat.Jpeg, + externalCropRequest(outputFormat = "JPEG").saveFormat, + ) + assertEquals( + SaveFormat.Jpeg, + externalCropRequest(outputFormat = "image/jpg").saveFormat, + ) + assertEquals( + SaveFormat.Jpeg, + externalCropRequest(outputFormat = "avatar.jpg").saveFormat, + ) + } + + @Test + fun saveFormat_defaultsToJpeg() { + assertEquals( + SaveFormat.Jpeg, + externalCropRequest(outputFormat = null).saveFormat, + ) + assertEquals( + SaveFormat.Jpeg, + externalCropRequest(outputFormat = "heic").saveFormat, + ) + } + + private fun externalCropRequest( + outputX: Int = 0, + outputY: Int = 0, + aspectX: Int = 0, + aspectY: Int = 0, + outputFormat: String? = null, + ): ExternalCropRequest { + return ExternalCropRequest( + sourceUri = Uri.parse("content://test/source"), + outputUri = null, + outputX = outputX, + outputY = outputY, + scale = true, + scaleUpIfNeeded = true, + aspectX = aspectX, + aspectY = aspectY, + returnData = false, + outputFormat = outputFormat, + ) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepositoryTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepositoryTest.kt new file mode 100644 index 0000000000..9d8c27121e --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/AiMediaAnalysisRepositoryTest.kt @@ -0,0 +1,537 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.room.Room +import com.dot.gallery.core.dataStore +import com.dot.gallery.feature_node.data.data_source.GeneratedMediaCategoryStaging +import com.dot.gallery.feature_node.data.data_source.InternalDatabase +import com.dot.gallery.feature_node.data.model.Category +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.model.MediaCategory +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +internal class AiMediaAnalysisRepositoryTest { + private lateinit var database: InternalDatabase + private lateinit var repository: AiMediaAnalysisRepository + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + runBlocking { + context.dataStore.edit { preferences -> preferences.clear() } + } + database = Room.inMemoryDatabaseBuilder(context, InternalDatabase::class.java) + .allowMainThreadQueries() + .build() + repository = AiMediaAnalysisRepositoryImpl(context = context, database = database) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun clearAllGeneratedData_preservesCategoryDefinitionAndManualMembership() { + runTest { + val category = Category( + id = CATEGORY_ID, + name = "User category", + searchTerms = "sunset", + embedding = floatArrayOf(0.1f, 0.2f), + referenceImageIds = listOf(MEDIA_ID), + isUserCreated = true, + ) + database.getCategoryDao().insertCategory(category = category) + database.getImageEmbeddingDao().addImageEmbedding( + ImageEmbedding( + id = MEDIA_ID, + date = 10L, + embedding = floatArrayOf(0.3f, 0.4f), + ), + ) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.9f, + isManuallyAdded = true, + ), + ) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = OTHER_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + ), + ) + + repository.clearAllGeneratedData() + + assertNull(database.getImageEmbeddingDao().getRecord(id = MEDIA_ID)) + assertEquals( + listOf(MEDIA_ID), + database.getCategoryDao().getAllClassifiedMediaIds(), + ) + val preservedCategory = database.getCategoryDao() + .getCategoryById(categoryId = CATEGORY_ID) + assertEquals(category.name, preservedCategory?.name) + assertEquals(category.searchTerms, preservedCategory?.searchTerms) + assertEquals(category.referenceImageIds, preservedCategory?.referenceImageIds) + assertNull(preservedCategory?.embedding) + assertEquals(1, database.getCategoryDao().getAllCategories().first().size) + } + } + + @Test + fun clearAllGeneratedData_removesInterruptedClassificationState() { + runTest { + insertInterruptedClassificationState() + + repository.clearAllGeneratedData() + + assertInterruptedClassificationStateCleared() + } + } + + @Test + fun invalidateGeneratedData_preservesManualMappingForChangedMedia() { + runTest { + database.getCategoryDao().insertCategory( + category = Category( + id = CATEGORY_ID, + name = "User category", + searchTerms = "sunset", + embedding = floatArrayOf(0.1f, 0.2f), + isUserCreated = true, + ), + ) + listOf(MEDIA_ID, OTHER_MEDIA_ID).forEach { mediaId -> + database.getImageEmbeddingDao().addImageEmbedding( + ImageEmbedding( + id = mediaId, + date = 10L, + embedding = floatArrayOf(0.3f, 0.4f), + ), + ) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = mediaId, + categoryId = CATEGORY_ID, + similarityScore = 0.9f, + isManuallyAdded = mediaId == MEDIA_ID, + ), + ) + } + + repository.invalidateGeneratedData(mediaIds = setOf(MEDIA_ID)) + + assertNull(database.getImageEmbeddingDao().getRecord(id = MEDIA_ID)) + assertEquals( + setOf(OTHER_MEDIA_ID), + database.getImageEmbeddingDao().getRecords().first().map { record -> record.id } + .toSet(), + ) + assertEquals( + setOf(MEDIA_ID, OTHER_MEDIA_ID), + database.getCategoryDao().getAllClassifiedMediaIds().toSet(), + ) + } + } + + @Test + fun removeMediaData_removesGeneratedAndManualMappingsForDeletedMedia() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + + repository.removeMediaData(mediaIds = setOf(MEDIA_ID)) + + assertTrue(database.getCategoryDao().getAllClassifiedMediaIds().isEmpty()) + } + } + + @Test + fun clearCategoryGeneratedData_preservesManualMembership() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = OTHER_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + ), + ) + + repository.clearCategoryGeneratedData() + + assertEquals( + listOf(MEDIA_ID), + database.getCategoryDao().getAllClassifiedMediaIds(), + ) + } + } + + @Test + fun clearCategoryGeneratedData_removesInterruptedClassificationState() { + runTest { + insertInterruptedClassificationState() + + repository.clearCategoryGeneratedData() + + assertInterruptedClassificationStateCleared() + } + } + + @Test + fun resetAllCategoryData_removesInterruptedClassificationState() { + runTest { + insertInterruptedClassificationState() + + database.getCategoryDao().resetAllCategoryData() + + assertInterruptedClassificationStateCleared() + } + } + + @Test + fun reclassifyMediaForCategory_preservesManualMembershipAndAddsGeneratedMatches() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + + database.getCategoryDao().reclassifyMediaForCategory( + categoryId = CATEGORY_ID, + mediaCategories = listOf( + MediaCategory( + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.1f, + ), + MediaCategory( + mediaId = OTHER_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + ), + ), + ) + + assertEquals( + setOf(MEDIA_ID, OTHER_MEDIA_ID), + database.getCategoryDao().getAllClassifiedMediaIds().toSet(), + ) + assertEquals( + 0.9f, + database.getCategoryDao().getSimilarityScore( + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + ), + ) + } + } + + @Test + fun publishingGeneratedStaging_isAtomicAndPreservesManualMembership() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = OTHER_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + ), + ) + database.getCategoryDao().beginGeneratedMediaCategoryStaging( + generationId = FIRST_GENERATION_ID, + ) + database.getCategoryDao().insertGeneratedMediaCategoryStaging( + mediaCategories = listOf( + GeneratedMediaCategoryStaging( + generationId = FIRST_GENERATION_ID, + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.1f, + addedAt = 1L, + ), + GeneratedMediaCategoryStaging( + generationId = FIRST_GENERATION_ID, + mediaId = THIRD_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.7f, + addedAt = 2L, + ), + ), + ) + + val published = database.getCategoryDao().replaceGeneratedMediaCategoriesFromStaging( + generationId = FIRST_GENERATION_ID, + ) + + assertTrue(published) + assertEquals( + setOf(MEDIA_ID, THIRD_MEDIA_ID), + database.getCategoryDao().getAllClassifiedMediaIds().toSet(), + ) + val manualMapping = database.getCategoryDao().getMediaCategory( + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + ) + assertEquals(true, manualMapping?.isManuallyAdded) + assertEquals(0.9f, manualMapping?.similarityScore) + } + } + + @Test + fun overlappingClassificationGenerations_publishOnlyTheAuthoritativeGeneration() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + val categoryDao = database.getCategoryDao() + categoryDao.beginGeneratedMediaCategoryStaging(generationId = FIRST_GENERATION_ID) + categoryDao.insertGeneratedMediaCategoryStaging( + mediaCategories = listOf( + stagedMapping( + generationId = FIRST_GENERATION_ID, + mediaId = OTHER_MEDIA_ID, + ), + ), + ) + + categoryDao.beginGeneratedMediaCategoryStaging(generationId = SECOND_GENERATION_ID) + categoryDao.insertGeneratedMediaCategoryStaging( + mediaCategories = listOf( + stagedMapping( + generationId = SECOND_GENERATION_ID, + mediaId = THIRD_MEDIA_ID, + ), + ), + ) + categoryDao.abandonGeneratedMediaCategoryStaging(generationId = FIRST_GENERATION_ID) + + val stalePublished = categoryDao.replaceGeneratedMediaCategoriesFromStaging( + generationId = FIRST_GENERATION_ID, + ) + val authoritativePublished = categoryDao.replaceGeneratedMediaCategoriesFromStaging( + generationId = SECOND_GENERATION_ID, + ) + + assertEquals(false, stalePublished) + assertEquals(true, authoritativePublished) + assertEquals( + setOf(MEDIA_ID, THIRD_MEDIA_ID), + categoryDao.getAllClassifiedMediaIds().toSet(), + ) + } + } + + @Test + fun clearedClassificationState_rejectsFurtherStagingFromInterruptedGeneration() { + runTest { + val categoryDao = database.getCategoryDao() + categoryDao.beginGeneratedMediaCategoryStaging(generationId = FIRST_GENERATION_ID) + categoryDao.clearGeneratedMediaCategoryStagingState() + + val staged = categoryDao.stageGeneratedMediaCategories( + generationId = FIRST_GENERATION_ID, + mediaCategories = listOf( + stagedMapping( + generationId = FIRST_GENERATION_ID, + mediaId = MEDIA_ID, + ), + ), + ) + + assertEquals(false, staged) + assertInterruptedClassificationStateCleared() + } + } + + @Test + fun largeChangedAndRemovedIdSets_areProcessedWithoutExceedingSqliteBindLimits() { + runTest { + database.getCategoryDao().insertCategory( + category = Category( + id = CATEGORY_ID, + name = "Generated category", + searchTerms = "term", + ), + ) + val mediaIds = (1L..LARGE_ID_SET_SIZE.toLong()).toSet() + val embeddings = mediaIds.map { mediaId -> + ImageEmbedding( + id = mediaId, + date = 1L, + embedding = floatArrayOf(0.1f), + ) + } + val mappings = mediaIds.map { mediaId -> + MediaCategory( + mediaId = mediaId, + categoryId = CATEGORY_ID, + similarityScore = 0.5f, + ) + } + database.getImageEmbeddingDao().addImageEmbeddings(imageEmbeddings = embeddings) + database.getCategoryDao().insertGeneratedMediaCategories(mediaCategories = mappings) + + repository.invalidateGeneratedData(mediaIds = mediaIds) + + assertEquals(0, database.getImageEmbeddingDao().getCount()) + assertTrue(database.getCategoryDao().getAllClassifiedMediaIds().isEmpty()) + + database.getImageEmbeddingDao().addImageEmbeddings(imageEmbeddings = embeddings) + database.getCategoryDao().insertGeneratedMediaCategories(mediaCategories = mappings) + + repository.removeMediaData(mediaIds = mediaIds) + + assertEquals(0, database.getImageEmbeddingDao().getCount()) + assertTrue(database.getCategoryDao().getAllClassifiedMediaIds().isEmpty()) + } + } + + @Test + fun disablingAnalysis_persistsCleanupPendingUntilCleanupCompletes() { + runTest { + repository.setAnalysisEnabled(enabled = false) + + val pendingPreferences = repository.getPreferences() + assertTrue(pendingPreferences.analysisCleanupPending) + assertTrue(pendingPreferences.categoryCleanupPending) + + repository.completeAnalysisCleanup() + + val completedPreferences = repository.getPreferences() + assertEquals(false, completedPreferences.analysisCleanupPending) + assertEquals(false, completedPreferences.categoryCleanupPending) + } + } + + @Test + fun disablingCategories_persistsCleanupPendingUntilCleanupCompletes() { + runTest { + repository.setCategoryClassificationEnabled(enabled = false) + + assertTrue(repository.getPreferences().categoryCleanupPending) + + repository.completeCategoryCleanup() + + assertEquals(false, repository.getPreferences().categoryCleanupPending) + } + } + + @Test + fun reenablingAnalysis_preservesPendingCleanupWhenCategoriesRemainDisabled() { + runTest { + repository.setCategoryClassificationEnabled(enabled = false) + repository.setAnalysisEnabled(enabled = false) + + repository.setAnalysisEnabled(enabled = true) + repository.completeAnalysisCleanup() + + val preferences = repository.getPreferences() + assertTrue(preferences.analysisEnabled) + assertEquals(false, preferences.analysisCleanupPending) + assertEquals(false, preferences.categoryClassificationEnabled) + assertTrue(preferences.categoryCleanupPending) + } + } + + @Test + fun getClassifiedMediaIdPage_returnsManualAndGeneratedMappingsInKeysetOrder() { + runTest { + insertCategoryAndMembership(isManuallyAdded = true) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = OTHER_MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + isManuallyAdded = true, + ), + ) + + val firstPage = repository.getClassifiedMediaIdPage( + afterId = Long.MIN_VALUE, + limit = 1, + ) + val secondPage = repository.getClassifiedMediaIdPage( + afterId = firstPage.last(), + limit = 1, + ) + + assertEquals(listOf(MEDIA_ID), firstPage) + assertEquals(listOf(OTHER_MEDIA_ID), secondPage) + } + } + + private suspend fun insertCategoryAndMembership(isManuallyAdded: Boolean) { + database.getCategoryDao().insertCategory( + category = Category( + id = CATEGORY_ID, + name = "User category", + searchTerms = "sunset", + embedding = floatArrayOf(0.1f, 0.2f), + isUserCreated = true, + ), + ) + database.getCategoryDao().insertMediaCategory( + mediaCategory = MediaCategory( + mediaId = MEDIA_ID, + categoryId = CATEGORY_ID, + similarityScore = 0.9f, + isManuallyAdded = isManuallyAdded, + ), + ) + } + + private suspend fun insertInterruptedClassificationState() { + val categoryDao = database.getCategoryDao() + categoryDao.beginGeneratedMediaCategoryStaging(generationId = FIRST_GENERATION_ID) + categoryDao.insertGeneratedMediaCategoryStaging( + mediaCategories = listOf( + stagedMapping( + generationId = FIRST_GENERATION_ID, + mediaId = MEDIA_ID, + ), + ), + ) + } + + private suspend fun assertInterruptedClassificationStateCleared() { + val categoryDao = database.getCategoryDao() + assertEquals(0, categoryDao.getGeneratedMediaCategoryStagingCount()) + assertNull(categoryDao.getCategoryClassificationGeneration()) + } + + private fun stagedMapping( + generationId: String, + mediaId: Long, + ): GeneratedMediaCategoryStaging { + return GeneratedMediaCategoryStaging( + generationId = generationId, + mediaId = mediaId, + categoryId = CATEGORY_ID, + similarityScore = 0.8f, + addedAt = 1L, + ) + } + + companion object { + private const val CATEGORY_ID = 42L + private const val FIRST_GENERATION_ID = "first" + private const val LARGE_ID_SET_SIZE = 1_001 + private const val MEDIA_ID = 7L + private const val OTHER_MEDIA_ID = 8L + private const val SECOND_GENERATION_ID = "second" + private const val THIRD_MEDIA_ID = 9L + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepositoryTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepositoryTest.kt new file mode 100644 index 0000000000..efac791b3b --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/ExternalCropRepositoryTest.kt @@ -0,0 +1,543 @@ +package com.dot.gallery.feature_node.data.repository + +import android.app.PendingIntent +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Intent +import android.content.IntentSender +import android.database.Cursor +import android.database.MatrixCursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.net.Uri +import android.provider.MediaStore +import android.provider.OpenableColumns +import com.dot.gallery.feature_node.data.model.editor.SaveFormat +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ExternalCropRepositoryTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun saveCropResult_withoutExplicitOutput_usesCroppedDisplayNameForInsertedOutput() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sourceUri = Uri.parse("content://test/source") + val outputUri = Uri.parse("content://media/external/images/media/42") + val insertedValues = slot() + val publishedValues = slot() + val contentResolver = mockk() + every { + contentResolver.query( + sourceUri, + any(), + null, + null, + null, + ) + } returns displayNameCursor(displayName = "avatar.png") + every { + contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + capture(insertedValues), + ) + } returns outputUri + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns ByteArrayOutputStream() + every { + contentResolver.update( + outputUri, + capture(publishedValues), + null, + null, + ) + } returns 1 + val repository = ExternalCropRepositoryImpl( + contentResolver = contentResolver, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest( + sourceUri = sourceUri, + outputFormat = "image/jpeg", + ), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertEquals(outputUri, assertSaved(resultIntent).data) + assertEquals( + "avatar-cropped.jpg", + insertedValues.captured.getAsString(MediaStore.MediaColumns.DISPLAY_NAME), + ) + assertEquals( + SaveFormat.Jpeg.mimeType, + insertedValues.captured.getAsString(MediaStore.MediaColumns.MIME_TYPE), + ) + assertEquals( + 0, + publishedValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + } + } + + @Test + fun saveCropResult_withExplicitOutput_returnsExplicitOutputUri() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://test/output") + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns ByteArrayOutputStream() + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest(outputUri = outputUri), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertEquals(outputUri, assertSaved(resultIntent).data) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } + } + + @Test + fun saveCropResult_whenExplicitOutputWriteFails_returnsFailed() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://test/output") + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns null + every { + contentResolver.openOutputStream(outputUri) + } returns null + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest(outputUri = outputUri), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertFailed(resultIntent) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } + } + + /** + * Scoped storage denies writes to media we do not own, which is the normal case for a + * caller-supplied output uri. That is recoverable by asking the user, not a hard failure. + */ + @Test + fun saveCropResult_whenExplicitOutputWriteIsDenied_requestsWritePermission() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://media/external/images/media/42") + val intentSender = mockk() + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } throws SecurityException("no access") + every { + contentResolver.openOutputStream(outputUri) + } throws SecurityException("no access") + mockkStatic(MediaStore::class) + every { + MediaStore.createWriteRequest(contentResolver, listOf(outputUri)) + } returns mockk { + every { this@mockk.intentSender } returns intentSender + } + val repository = externalCropRepository(contentResolver = contentResolver) + + try { + val resultIntent = repository.saveCropResult( + request = externalCropRequest(outputUri = outputUri), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertEquals( + ExternalCropSaveResult.OutputPermissionRequired(intentSender = intentSender), + resultIntent, + ) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } finally { + unmockkStatic(MediaStore::class) + } + } + } + + @Test + fun saveCropResult_whenFallbackWriteFails_deletesInsertedOutput() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sourceUri = Uri.parse("content://test/source") + val outputUri = Uri.parse("content://media/external/images/media/42") + val contentResolver = mockk() + every { + contentResolver.query( + sourceUri, + any(), + null, + null, + null, + ) + } returns displayNameCursor(displayName = "avatar.png") + every { + contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + any(), + ) + } returns outputUri + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns null + every { + contentResolver.openOutputStream(outputUri) + } returns null + every { + contentResolver.delete(outputUri, null, null) + } returns 1 + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest(sourceUri = sourceUri), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertFailed(resultIntent) + verify(exactly = 1) { + contentResolver.delete(outputUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(outputUri, any(), null, null) + } + } + } + + @Test + fun saveCropResult_whenFallbackPublishFails_deletesInsertedOutput() { + runTest(context = mainDispatcherRule.testDispatcher) { + val sourceUri = Uri.parse("content://test/source") + val outputUri = Uri.parse("content://media/external/images/media/42") + val contentResolver = mockk() + every { + contentResolver.query( + sourceUri, + any(), + null, + null, + null, + ) + } returns displayNameCursor(displayName = "avatar.png") + every { + contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + any(), + ) + } returns outputUri + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns ByteArrayOutputStream() + every { + contentResolver.update(outputUri, any(), null, null) + } returns 0 + every { + contentResolver.delete(outputUri, null, null) + } returns 1 + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest(sourceUri = sourceUri), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + assertFailed(resultIntent) + verify(exactly = 1) { + contentResolver.delete(outputUri, null, null) + } + } + } + + @Test + fun saveCropResult_whenReturnDataIsRequested_doesNotInsertFallbackOutput() { + runTest(context = mainDispatcherRule.testDispatcher) { + val contentResolver = mockk() + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest(returnData = true), + image = cropImage(), + normalizedRect = fullCropRect(), + ) + + val savedIntent = assertSaved(resultIntent) + assertNull(savedIntent.data) + assertEquals(true, savedIntent.hasExtra("data")) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } + } + + @Test + fun saveCropResult_whenScaleIsDisabledAndCropIsSmaller_centersCropOnRequestedCanvas() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://test/output") + val outputStream = ByteArrayOutputStream() + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns outputStream + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest( + outputUri = outputUri, + outputX = 4, + outputY = 4, + scale = false, + outputFormat = "image/png", + ), + image = solidCropImage( + width = 2, + height = 2, + color = Color.RED, + ), + normalizedRect = fullCropRect(), + ) + + val decodedBitmap = decodeWrittenBitmap(outputStream = outputStream) + assertSaved(resultIntent) + assertEquals(4, decodedBitmap.width) + assertEquals(4, decodedBitmap.height) + assertEquals(Color.TRANSPARENT, decodedBitmap.getPixel(0, 0)) + assertEquals(Color.RED, decodedBitmap.getPixel(1, 1)) + assertEquals(Color.RED, decodedBitmap.getPixel(2, 2)) + } + } + + @Test + fun saveCropResult_whenScaleIsDisabledAndCropIsLarger_usesCenteredSourceRegion() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://test/output") + val outputStream = ByteArrayOutputStream() + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns outputStream + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest( + outputUri = outputUri, + outputX = 2, + outputY = 2, + scale = false, + outputFormat = "image/png", + ), + image = patternedCropImage(), + normalizedRect = fullCropRect(), + ) + + val decodedBitmap = decodeWrittenBitmap(outputStream = outputStream) + assertSaved(resultIntent) + assertEquals(2, decodedBitmap.width) + assertEquals(2, decodedBitmap.height) + assertEquals(patternColor(x = 1, y = 1), decodedBitmap.getPixel(0, 0)) + assertEquals(patternColor(x = 2, y = 1), decodedBitmap.getPixel(1, 0)) + assertEquals(patternColor(x = 1, y = 2), decodedBitmap.getPixel(0, 1)) + assertEquals(patternColor(x = 2, y = 2), decodedBitmap.getPixel(1, 1)) + } + } + + @Test + fun saveCropResult_whenScaleIsEnabled_scalesToRequestedOutput() { + runTest(context = mainDispatcherRule.testDispatcher) { + val outputUri = Uri.parse("content://test/output") + val outputStream = ByteArrayOutputStream() + val contentResolver = mockk() + every { + contentResolver.openOutputStream(outputUri, "wt") + } returns outputStream + val repository = externalCropRepository(contentResolver = contentResolver) + + val resultIntent = repository.saveCropResult( + request = externalCropRequest( + outputUri = outputUri, + outputX = 4, + outputY = 4, + scale = true, + scaleUpIfNeeded = true, + outputFormat = "image/png", + ), + image = solidCropImage( + width = 2, + height = 2, + color = Color.RED, + ), + normalizedRect = fullCropRect(), + ) + + val decodedBitmap = decodeWrittenBitmap(outputStream = outputStream) + assertSaved(resultIntent) + assertEquals(4, decodedBitmap.width) + assertEquals(4, decodedBitmap.height) + assertEquals(Color.RED, decodedBitmap.getPixel(0, 0)) + assertEquals(Color.RED, decodedBitmap.getPixel(3, 3)) + } + } + + private fun externalCropRequest( + sourceUri: Uri = Uri.parse("content://test/source"), + outputUri: Uri? = null, + outputX: Int = 0, + outputY: Int = 0, + scale: Boolean = true, + scaleUpIfNeeded: Boolean = true, + returnData: Boolean = false, + outputFormat: String? = null, + ): ExternalCropRequest { + return ExternalCropRequest( + sourceUri = sourceUri, + outputUri = outputUri, + outputX = outputX, + outputY = outputY, + scale = scale, + scaleUpIfNeeded = scaleUpIfNeeded, + aspectX = 0, + aspectY = 0, + returnData = returnData, + outputFormat = outputFormat, + ) + } + + private fun externalCropRepository(contentResolver: ContentResolver): ExternalCropRepository { + return ExternalCropRepositoryImpl( + contentResolver = contentResolver, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ioDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun cropImage(): CropImage { + val bitmap = Bitmap.createBitmap( + 4, + 4, + Bitmap.Config.ARGB_8888, + ) + return CropImage( + sourceBitmap = bitmap, + previewBitmap = bitmap, + ) + } + + private fun solidCropImage(width: Int, height: Int, color: Int): CropImage { + val bitmap = Bitmap.createBitmap( + width, + height, + Bitmap.Config.ARGB_8888, + ) + bitmap.eraseColor(color) + + return CropImage( + sourceBitmap = bitmap, + previewBitmap = bitmap, + ) + } + + private fun patternedCropImage(): CropImage { + val bitmap = Bitmap.createBitmap( + 4, + 4, + Bitmap.Config.ARGB_8888, + ) + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + bitmap.setPixel(x, y, patternColor(x = x, y = y)) + } + } + + return CropImage( + sourceBitmap = bitmap, + previewBitmap = bitmap, + ) + } + + private fun patternColor(x: Int, y: Int): Int { + return Color.rgb( + x * 40, + y * 40, + (x + y) * 20, + ) + } + + private fun decodeWrittenBitmap(outputStream: ByteArrayOutputStream): Bitmap { + val bytes = outputStream.toByteArray() + return requireNotNull( + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + ), + ) + } + + private fun fullCropRect(): NormalizedCropRect { + return NormalizedCropRect( + left = 0f, + top = 0f, + right = 1f, + bottom = 1f, + ) + } + + private fun displayNameCursor(displayName: String): Cursor { + return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME)).apply { + addRow(arrayOf(displayName)) + } + } + + private fun assertSaved(result: ExternalCropSaveResult): Intent { + assertTrue("Expected a saved result but was $result", result is ExternalCropSaveResult.Saved) + return (result as ExternalCropSaveResult.Saved).resultIntent + } + + private fun assertFailed(result: ExternalCropSaveResult) { + assertEquals(ExternalCropSaveResult.Failed, result) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryTest.kt new file mode 100644 index 0000000000..ff134eec8f --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MediaCopyRepositoryTest.kt @@ -0,0 +1,527 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.res.AssetFileDescriptor +import android.net.Uri +import android.provider.MediaStore +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private enum class OutputFailurePoint { + WRITE, + FLUSH, + CLOSE, +} + +@RunWith(RobolectricTestRunner::class) +class MediaCopyRepositoryTest { + + @Test + fun copyMedia_whenCopyAndPublishSucceed_returnsPublishedUri() { + runTest { + val sourceBytes = byteArrayOf(1, 2, 3, 4) + val outputStream = ByteArrayOutputStream() + val pendingValues = slot() + val publishedValues = slot() + val contentResolver = mockk() + stubInsertedImage( + contentResolver = contentResolver, + pendingValues = pendingValues, + ) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(sourceBytes) + every { contentResolver.openOutputStream(destinationUri) } returns outputStream + every { + contentResolver.update( + destinationUri, + capture(publishedValues), + null, + null, + ) + } returns 1 + val copiedByteCounts = mutableListOf() + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + ) { bytesCopied -> + copiedByteCounts += bytesCopied + } + + assertEquals(destinationUri, result) + assertArrayEquals(sourceBytes, outputStream.toByteArray()) + assertEquals(sourceBytes.size, copiedByteCounts.sum()) + assertEquals( + 1, + pendingValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + assertEquals( + "Pictures/MediaCopyTest", + pendingValues.captured.getAsString(MediaStore.MediaColumns.RELATIVE_PATH), + ) + assertEquals( + 0, + publishedValues.captured.getAsInteger(MediaStore.MediaColumns.IS_PENDING), + ) + verify(exactly = 0) { + contentResolver.delete(destinationUri, null, null) + } + } + } + + @Test + fun copyMedia_whenInputStreamIsNull_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns null + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(destinationUri, any(), null, null) + } + } + } + + @Test + fun copyMedia_whenOutputStreamIsNull_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns null + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(destinationUri, any(), null, null) + } + } + } + + @Test + fun copyMedia_whenCopyThrows_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns failingInputStream() + every { contentResolver.openOutputStream(destinationUri) } returns + ByteArrayOutputStream() + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(destinationUri, any(), null, null) + } + } + } + + @Test + fun copyMedia_whenOutputWriteThrows_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns + failingOutputStream(failurePoint = OutputFailurePoint.WRITE) + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verifyFailedCopyCleanup(contentResolver = contentResolver) + } + } + + @Test + fun copyMedia_whenOutputFlushThrows_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns + failingOutputStream(failurePoint = OutputFailurePoint.FLUSH) + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verifyFailedCopyCleanup(contentResolver = contentResolver) + } + } + + @Test + fun copyMedia_whenOutputCloseThrows_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns + failingOutputStream(failurePoint = OutputFailurePoint.CLOSE) + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verifyFailedCopyCleanup(contentResolver = contentResolver) + } + } + + @Test + fun copyMedia_whenPublishFails_deletesPendingDestination() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns + ByteArrayOutputStream() + every { + contentResolver.update(destinationUri, any(), null, null) + } returns 0 + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + } + } + + @Test + fun copyMedia_whenCancelled_deletesPendingDestinationAndRethrowsCancellation() { + runTest { + val cancellationException = CancellationException("cancelled") + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(destinationUri) } returns + ByteArrayOutputStream() + every { contentResolver.delete(destinationUri, null, null) } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val thrownException = try { + repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + ) { + throw cancellationException + } + null + } catch (exception: CancellationException) { + exception + } + + assertSame(cancellationException, thrownException) + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(destinationUri, any(), null, null) + } + } + } + + @Test + fun copyMedia_whenCleanupThrows_returnsFailure() { + runTest { + val contentResolver = mockk() + stubInsertedImage(contentResolver = contentResolver) + every { contentResolver.openInputStream(sourceUri) } returns null + every { + contentResolver.delete(destinationUri, null, null) + } throws IllegalStateException("delete failed") + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + } + } + + @Test + fun copyMedia_whenInsertFails_returnsFailureWithoutOpeningStreams() { + runTest { + val contentResolver = mockk() + every { contentResolver.getType(sourceUri) } returns "image/jpeg" + every { + contentResolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + any(), + ) + } returns null + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.openInputStream(any()) + } + verify(exactly = 0) { + contentResolver.delete(any(), null, null) + } + } + } + + @Test + fun copyMedia_whenCopyingVideoToSecondaryStorage_usesVideoCollection() { + runTest { + val secondaryDestinationUri = + Uri.parse("content://media/71f8-2c0a/video/media/2") + val contentResolver = mockk() + every { contentResolver.getType(sourceUri) } returns "video/mp4" + every { + contentResolver.insert( + MediaStore.Video.Media.getContentUri("71f8-2c0a"), + any(), + ) + } returns secondaryDestinationUri + every { contentResolver.openInputStream(sourceUri) } returns + ByteArrayInputStream(byteArrayOf(1)) + every { contentResolver.openOutputStream(secondaryDestinationUri) } returns + ByteArrayOutputStream() + every { + contentResolver.update(secondaryDestinationUri, any(), null, null) + } returns 1 + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = "/storage/71F8-2C0A/Movies/Copied", + onBytesCopied = {}, + ) + + assertEquals(secondaryDestinationUri, result) + } + } + + @Test + fun copyMedia_whenMimeTypeIsUnsupported_doesNotInsertDestination() { + runTest { + val contentResolver = mockk() + every { contentResolver.getType(sourceUri) } returns "application/pdf" + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } + } + + @Test + fun copyMedia_whenMimeTypeIsNull_doesNotInsertDestination() { + runTest { + val contentResolver = mockk() + every { contentResolver.getType(sourceUri) } returns null + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.copyMedia( + sourceUri = sourceUri, + destinationPath = DESTINATION_PATH, + onBytesCopied = {}, + ) + + assertNull(result) + verify(exactly = 0) { + contentResolver.insert(any(), any()) + } + } + } + + @Test + fun getMediaSize_whenDescriptorHasLength_returnsLength() { + runTest { + val descriptor = mockk(relaxed = true) + every { descriptor.length } returns 42L + val contentResolver = mockk() + every { + contentResolver.openAssetFileDescriptor(sourceUri, "r") + } returns descriptor + val repository = mediaCopyRepository(contentResolver = contentResolver) + + val result = repository.getMediaSize(uri = sourceUri) + + assertEquals(42L, result) + } + } + + @Test + fun notifyMediaChanged_notifiesExternalFilesCollection() { + runTest { + val contentResolver = mockk() + every { contentResolver.notifyChange(any(), null) } returns Unit + val repository = mediaCopyRepository(contentResolver = contentResolver) + + repository.notifyMediaChanged() + + verify(exactly = 1) { + contentResolver.notifyChange( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + null, + ) + } + } + } + + private fun mediaCopyRepository(contentResolver: ContentResolver): MediaCopyRepository { + return MediaCopyRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = Dispatchers.Unconfined, + ) + } + + private fun stubInsertedImage( + contentResolver: ContentResolver, + pendingValues: CapturingSlot? = null, + ) { + every { contentResolver.getType(sourceUri) } returns "image/jpeg" + every { + contentResolver.insert( + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + if (pendingValues == null) any() else capture(pendingValues), + ) + } returns destinationUri + } + + private fun verifyFailedCopyCleanup(contentResolver: ContentResolver) { + verify(exactly = 1) { + contentResolver.delete(destinationUri, null, null) + } + verify(exactly = 0) { + contentResolver.update(destinationUri, any(), null, null) + } + } + + private fun failingOutputStream(failurePoint: OutputFailurePoint): OutputStream { + return object : ByteArrayOutputStream() { + override fun write(buffer: ByteArray, offset: Int, length: Int) { + if (failurePoint == OutputFailurePoint.WRITE) { + throw IOException("write failed") + } + super.write(buffer, offset, length) + } + + override fun flush() { + if (failurePoint == OutputFailurePoint.FLUSH) { + throw IOException("flush failed") + } + super.flush() + } + + override fun close() { + if (failurePoint == OutputFailurePoint.CLOSE) { + throw IOException("close failed") + } + super.close() + } + } + } + + private fun failingInputStream(): InputStream { + return object : InputStream() { + override fun read(): Int { + throw IOException("copy failed") + } + } + } + + companion object { + private val sourceUri = Uri.parse("content://media/external/images/media/1") + private val destinationUri = Uri.parse("content://media/external/images/media/2") + private const val DESTINATION_PATH = "Pictures/MediaCopyTest" + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarkerTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarkerTest.kt new file mode 100644 index 0000000000..f87a916550 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/MotionPhotoMarkerTest.kt @@ -0,0 +1,55 @@ +package com.dot.gallery.feature_node.data.repository + +import java.io.ByteArrayInputStream +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MotionPhotoMarkerTest { + + @Test + fun `marker spanning buffers is found without loading whole input`() { + runTest { + val prefix = "prefix-".toByteArray() + val marker = "MotionPhoto_Data".toByteArray() + val video = "video-payload".toByteArray() + val input = prefix + marker + video + + val offset = findLastMotionPhotoMarkerOffset( + inputStream = ByteArrayInputStream(input), + bufferSize = prefix.size + 3, + ) + + assertEquals(video.size.toLong(), offset) + } + } + + @Test + fun `last marker wins when metadata contains an earlier marker`() { + runTest { + val marker = "MotionPhoto_Data".toByteArray() + val video = "final-video".toByteArray() + val input = marker + "metadata".toByteArray() + marker + video + + val offset = findLastMotionPhotoMarkerOffset( + inputStream = ByteArrayInputStream(input), + bufferSize = 5, + ) + + assertEquals(video.size.toLong(), offset) + } + } + + @Test + fun `missing marker returns null`() { + runTest { + val offset = findLastMotionPhotoMarkerOffset( + inputStream = ByteArrayInputStream("ordinary-image".toByteArray()), + bufferSize = 4, + ) + + assertNull(offset) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepositoryTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepositoryTest.kt new file mode 100644 index 0000000000..363d31f340 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/SecureReviewMediaRepositoryTest.kt @@ -0,0 +1,133 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.net.Uri +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class SecureReviewMediaRepositoryTest { + + @Test + fun loadMedia_createsOrderedReadOnlyImageAndVideoModels() { + runTest { + val imageUri = Uri.parse("content://media/external/images/media/10") + val videoUri = Uri.parse("content://camera/review/video") + val contentResolver = mockk() + every { contentResolver.getType(imageUri) } returns " Image/JPEG ; charset=binary" + every { contentResolver.getType(videoUri) } returns "video/mp4" + val repository = repository( + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + val media = repository.loadMedia( + request = AuthorizedSecureReviewRequest( + uris = persistentListOf(imageUri, videoUri), + ), + ) + + assertNotNull(media) + requireNotNull(media) + assertEquals(listOf(imageUri, videoUri), media.map { it.uri }) + assertEquals(listOf(-1L, -2L), media.map { it.id }) + assertEquals(listOf("image/jpeg", "video/mp4"), media.map { it.mimeType }) + assertTrue(media.all { it.albumID == -99L && it.path.isEmpty() }) + } + } + + @Test + fun loadMedia_rejectsUnsupportedMimeTypeWithoutPartialResult() { + runTest { + val imageUri = Uri.parse("content://camera/image/1") + val unsupportedUri = Uri.parse("content://camera/document/2") + val contentResolver = mockk() + every { contentResolver.getType(imageUri) } returns "image/png" + every { contentResolver.getType(unsupportedUri) } returns "application/pdf" + val repository = repository( + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + val media = repository.loadMedia( + request = AuthorizedSecureReviewRequest( + uris = persistentListOf(imageUri, unsupportedUri), + ), + ) + + assertNull(media) + } + } + + @Test + fun loadMedia_rejectsMissingMimeType() { + runTest { + val uri = Uri.parse("content://camera/image/1") + val contentResolver = mockk() + every { contentResolver.getType(uri) } returns null + val repository = repository( + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + val media = repository.loadMedia( + request = AuthorizedSecureReviewRequest( + uris = persistentListOf(uri), + ), + ) + + assertNull(media) + } + } + + @Test + fun loadMedia_rejectsProviderExceptionsWithoutPartialResult() { + runTest { + val uri = Uri.parse("content://camera/image/1") + val exceptions = listOf( + IllegalArgumentException("invalid URI"), + SecurityException("grant revoked"), + ) + + exceptions.forEach { exception -> + val contentResolver = mockk() + every { contentResolver.getType(uri) } throws exception + + val media = repository( + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).loadMedia( + request = AuthorizedSecureReviewRequest( + uris = persistentListOf(uri), + ), + ) + + assertNull(media) + } + } + } + + private fun repository( + contentResolver: ContentResolver, + ioDispatcher: CoroutineDispatcher, + ): SecureReviewMediaRepositoryImpl { + return SecureReviewMediaRepositoryImpl( + contentResolver = contentResolver, + ioDispatcher = ioDispatcher, + ) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepositoryTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepositoryTest.kt new file mode 100644 index 0000000000..5cd3430e1c --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/repository/WidgetRepositoryTest.kt @@ -0,0 +1,128 @@ +package com.dot.gallery.feature_node.data.repository + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.UriPermission +import android.net.Uri +import com.dot.gallery.feature_node.data.model.WidgetType +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +internal class WidgetRepositoryTest { + + private lateinit var applicationContext: Context + private lateinit var contentResolver: ContentResolver + private lateinit var repository: WidgetRepository + + @Before + fun setUp() { + applicationContext = RuntimeEnvironment.getApplication() + applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + contentResolver = mockk(relaxed = true) + val context = mockk() + every { context.contentResolver } returns contentResolver + every { context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } returns + applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + repository = WidgetRepositoryImpl(context = context) + } + + @After + fun tearDown() { + applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun replaceWidgetData_deduplicatesUrisWithoutReordering() { + val firstUri = Uri.parse("content://provider/first") + val secondUri = Uri.parse("content://provider/second") + every { contentResolver.persistedUriPermissions } returns emptyList() + every { + contentResolver.takePersistableUriPermission(any(), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } just runs + + val saved = repository.replaceWidgetData( + widgetId = 1, + type = WidgetType.GRID, + uris = listOf(firstUri, secondUri, firstUri), + ) + + assertEquals(true, saved) + assertEquals(listOf(firstUri, secondUri), repository.getMediaUris(widgetId = 1)) + } + + @Test + fun sharedUriGrant_isReleasedOnlyAfterLastWidgetIsDeleted() { + val sharedUri = Uri.parse("content://provider/shared") + val replacementUri = Uri.parse("content://provider/replacement") + val permission = mockk { + every { uri } returns sharedUri + every { isReadPermission } returns true + } + every { contentResolver.persistedUriPermissions } returns listOf(permission) + every { + contentResolver.takePersistableUriPermission(any(), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } just runs + every { + contentResolver.releasePersistableUriPermission(any(), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } just runs + repository.replaceWidgetData( + widgetId = 1, + type = WidgetType.SINGLE, + uris = listOf(sharedUri), + ) + repository.replaceWidgetData( + widgetId = 2, + type = WidgetType.SINGLE, + uris = listOf(sharedUri), + ) + + repository.replaceWidgetData( + widgetId = 1, + type = WidgetType.SINGLE, + uris = listOf(replacementUri), + ) + + verify(exactly = 0) { + contentResolver.releasePersistableUriPermission( + sharedUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + + val deleted = repository.deleteWidgetData(widgetId = 2) + + assertEquals(true, deleted) + assertNull(repository.getWidgetData(widgetId = 2)) + assertFalse(repository.getMediaUris(widgetId = 1).contains(sharedUri)) + verify(exactly = 1) { + contentResolver.releasePersistableUriPermission( + sharedUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + } + + private companion object { + private const val PREFERENCES_NAME = "gallery_widget_prefs" + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverterTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverterTest.kt new file mode 100644 index 0000000000..a4b641e3a0 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/EmbeddingBlobConverterTest.kt @@ -0,0 +1,52 @@ +package com.dot.gallery.feature_node.data.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +internal class EmbeddingBlobConverterTest { + + private val converter = EmbeddingBlobConverter + + @Test + fun encodeAndDecode_preservesEmbedding() { + val embedding = floatArrayOf(-1.25f, 0f, 3.5f) + + val decoded = converter.decode(data = converter.encode(embedding = embedding)) + + assertArrayEquals(embedding, decoded, 0f) + } + + @Test + fun decode_rejectsTruncatedData() { + val encoded = converter.encode(embedding = floatArrayOf(1f, 2f)) + + assertThrows(IllegalArgumentException::class.java) { + converter.decode(data = encoded.copyOf(encoded.size - 1)) + } + } + + @Test + fun decode_rejectsUnknownFormat() { + val encoded = converter.encode(embedding = floatArrayOf(1f)).apply { + this[0] = 0 + } + + assertThrows(IllegalArgumentException::class.java) { + converter.decode(data = encoded) + } + } + + @Test + fun encode_rejectsEmptyAndNonFiniteEmbeddings() { + assertThrows(IllegalArgumentException::class.java) { + converter.encode(embedding = floatArrayOf()) + } + assertThrows(IllegalArgumentException::class.java) { + converter.encode(embedding = floatArrayOf(Float.NaN)) + } + assertThrows(IllegalArgumentException::class.java) { + converter.encode(embedding = floatArrayOf(Float.POSITIVE_INFINITY)) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolverTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolverTest.kt new file mode 100644 index 0000000000..7379573ac0 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/data/util/MediaStoreVolumeResolverTest.kt @@ -0,0 +1,38 @@ +package com.dot.gallery.feature_node.data.util + +import android.os.Environment +import android.provider.MediaStore +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MediaStoreVolumeResolverTest { + + @Test + fun resolveMediaStoreVolume_withPrimaryAbsolutePath_returnsPrimaryVolume() { + val primaryPath = Environment.getExternalStorageDirectory().absolutePath + + val result = resolveMediaStoreVolume(path = "$primaryPath/DCIM/Camera") + + assertEquals(MediaStore.VOLUME_EXTERNAL_PRIMARY, result.first) + assertEquals("DCIM/Camera", result.second) + } + + @Test + fun resolveMediaStoreVolume_withSecondaryAbsolutePath_returnsSecondaryVolume() { + val result = resolveMediaStoreVolume(path = "/storage/71F8-2C0A/DCIM/Camera") + + assertEquals("71f8-2c0a", result.first) + assertEquals("DCIM/Camera", result.second) + } + + @Test + fun resolveMediaStoreVolume_withRelativePath_returnsPrimaryVolume() { + val result = resolveMediaStoreVolume(path = "Pictures/Edited") + + assertEquals(MediaStore.VOLUME_EXTERNAL_PRIMARY, result.first) + assertEquals("Pictures/Edited", result.second) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequestTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequestTest.kt new file mode 100644 index 0000000000..c364812973 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/AuthorizeSecureReviewRequestTest.kt @@ -0,0 +1,254 @@ +package com.dot.gallery.feature_node.domain.securereview + +import android.app.ComponentCaller +import android.content.ClipData +import android.content.ClipDescription +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class AuthorizeSecureReviewRequestTest { + + private val authorizeSecureReviewRequest = AuthorizeSecureReviewRequestImpl() + + @Test + fun invoke_acceptsAuthorizedPrimaryAndSecondaryUrisInOrder() { + val primaryUri = Uri.parse("content://media/external/images/media/1") + val secondaryUri = Uri.parse("content://camera/review/2") + val caller = permissionCaller( + primaryUri to PackageManager.PERMISSION_GRANTED, + secondaryUri to PackageManager.PERMISSION_GRANTED, + ) + val intent = secureReviewIntent( + primaryUri = primaryUri, + secondaryUris = listOf(secondaryUri), + ) + + val request = authorizeSecureReviewRequest(intent = intent, caller = caller) + + assertEquals(listOf(primaryUri, secondaryUri), request?.uris) + } + + @Test + fun invoke_deduplicatesUrisWithoutChangingOrder() { + val primaryUri = Uri.parse("content://media/external/images/media/1") + val secondaryUri = Uri.parse("content://camera/review/2") + val caller = permissionCaller( + primaryUri to PackageManager.PERMISSION_GRANTED, + secondaryUri to PackageManager.PERMISSION_GRANTED, + ) + val intent = secureReviewIntent( + primaryUri = primaryUri, + secondaryUris = listOf(primaryUri, secondaryUri, primaryUri), + ) + + val request = authorizeSecureReviewRequest(intent = intent, caller = caller) + + assertEquals(listOf(primaryUri, secondaryUri), request?.uris) + } + + @Test + fun invoke_rejectsWrongActionAndMissingPrimaryUri() { + val caller = mockk(relaxed = true) + + assertNull( + authorizeSecureReviewRequest( + intent = Intent(Intent.ACTION_VIEW).setData(Uri.parse("content://camera/1")), + caller = caller, + ), + ) + assertNull( + authorizeSecureReviewRequest( + intent = Intent(MediaStore.ACTION_REVIEW_SECURE), + caller = caller, + ), + ) + } + + @Test + fun invoke_rejectsNonContentOpaqueBlankAuthorityAndFragmentUris() { + val caller = mockk(relaxed = true) + val invalidUris = listOf( + Uri.parse("file:///storage/emulated/0/DCIM/image.jpg"), + Uri.parse("https://example.com/image.jpg"), + Uri.parse("content:opaque"), + Uri.parse("content:///image/1"), + Uri.parse("content://camera/image/1#fragment"), + ) + + invalidUris.forEach { uri -> + assertNull( + authorizeSecureReviewRequest( + intent = secureReviewIntent(primaryUri = uri), + caller = caller, + ), + ) + } + } + + @Test + fun invoke_rejectsClipDataItemsContainingAnythingOtherThanUri() { + val primaryUri = Uri.parse("content://camera/image/1") + val caller = permissionCaller(primaryUri to PackageManager.PERMISSION_GRANTED) + val malformedItems = listOf( + ClipData.Item("text"), + ClipData.Item(Intent(Intent.ACTION_VIEW)), + ClipData.Item("text", "text"), + ) + + malformedItems.forEach { item -> + val intent = secureReviewIntent(primaryUri = primaryUri).apply { + clipData = ClipData( + ClipDescription("review", arrayOf("image/*")), + item, + ) + } + + assertNull(authorizeSecureReviewRequest(intent = intent, caller = caller)) + } + } + + @Test + fun invoke_rejectsMoreThanMaximumUris() { + val primaryUri = Uri.parse("content://camera/image/primary") + val secondaryUris = (1..AuthorizeSecureReviewRequest.MAXIMUM_URI_COUNT).map { index -> + Uri.parse("content://camera/image/$index") + } + + assertNull( + authorizeSecureReviewRequest( + intent = secureReviewIntent( + primaryUri = primaryUri, + secondaryUris = secondaryUris, + ), + caller = mockk(relaxed = true), + ), + ) + } + + @Test + fun invoke_acceptsMaximumNumberOfUris() { + val primaryUri = Uri.parse("content://camera/image/primary") + val secondaryUris = (1 until AuthorizeSecureReviewRequest.MAXIMUM_URI_COUNT).map { index -> + Uri.parse("content://camera/image/$index") + } + val caller = permissionCaller( + *(listOf(primaryUri) + secondaryUris) + .map { uri -> uri to PackageManager.PERMISSION_GRANTED } + .toTypedArray(), + ) + + val request = authorizeSecureReviewRequest( + intent = secureReviewIntent( + primaryUri = primaryUri, + secondaryUris = secondaryUris, + ), + caller = caller, + ) + + assertEquals(AuthorizeSecureReviewRequest.MAXIMUM_URI_COUNT, request?.uris?.size) + } + + @Test + fun invoke_rejectsRequestWhenAnyUriIsNotReadableByCaller() { + val primaryUri = Uri.parse("content://camera/image/1") + val deniedUri = Uri.parse("content://camera/image/2") + val caller = permissionCaller( + primaryUri to PackageManager.PERMISSION_GRANTED, + deniedUri to PackageManager.PERMISSION_DENIED, + ) + + assertNull( + authorizeSecureReviewRequest( + intent = secureReviewIntent( + primaryUri = primaryUri, + secondaryUris = listOf(deniedUri), + ), + caller = caller, + ), + ) + } + + @Test + fun invoke_rejectsPermissionCheckExceptions() { + val uri = Uri.parse("content://camera/image/1") + val exceptions = listOf( + IllegalArgumentException("not launch tracked"), + SecurityException("receiver cannot access URI"), + ) + + exceptions.forEach { exception -> + val caller = mockk() + every { + caller.checkContentUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } throws exception + + assertNull( + authorizeSecureReviewRequest( + intent = secureReviewIntent(primaryUri = uri), + caller = caller, + ), + ) + } + } + + @Test + fun invoke_preservesExactUriObject() { + val primaryUri = Uri.parse("content://camera/image/1?token=secret") + val caller = permissionCaller(primaryUri to PackageManager.PERMISSION_GRANTED) + + val request = authorizeSecureReviewRequest( + intent = secureReviewIntent(primaryUri = primaryUri), + caller = caller, + ) + + assertSame(primaryUri, request?.uris?.single()) + } + + private fun secureReviewIntent( + primaryUri: Uri, + secondaryUris: List = emptyList(), + ): Intent { + return Intent(MediaStore.ACTION_REVIEW_SECURE).apply { + data = primaryUri + if (secondaryUris.isNotEmpty()) { + clipData = ClipData( + ClipDescription("review", arrayOf("image/*")), + ClipData.Item(secondaryUris.first()), + ).apply { + secondaryUris.drop(1).forEach { uri -> + addItem(ClipData.Item(uri)) + } + } + } + } + } + + private fun permissionCaller( + vararg permissions: Pair, + ): ComponentCaller { + val caller = mockk() + permissions.forEach { (uri, permission) -> + every { + caller.checkContentUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } returns permission + } + return caller + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLockedTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLockedTest.kt new file mode 100644 index 0000000000..ca31366d13 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/securereview/IsDeviceLockedTest.kt @@ -0,0 +1,28 @@ +package com.dot.gallery.feature_node.domain.securereview + +import android.app.KeyguardManager +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class IsDeviceLockedTest { + + @Test + fun invoke_returnsKeyguardDeviceLockedState() { + val keyguardManager = mockk() + val isDeviceLocked = IsDeviceLockedImpl( + keyguardManager = keyguardManager, + ) + + every { keyguardManager.isDeviceLocked } returns true + assertTrue(isDeviceLocked()) + + every { keyguardManager.isDeviceLocked } returns false + assertFalse(isDeviceLocked()) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisSchedulingTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisSchedulingTest.kt new file mode 100644 index 0000000000..2bd2558a18 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisSchedulingTest.kt @@ -0,0 +1,246 @@ +package com.dot.gallery.feature_node.domain.use_case + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.impl.WorkManagerImpl +import androidx.work.testing.WorkManagerTestInitHelper +import com.dot.gallery.core.workers.CategoryWorker +import com.dot.gallery.core.workers.SearchIndexerUpdaterWorker +import com.dot.gallery.feature_node.data.model.AiMediaAnalysisPreferences +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.data.repository.MediaRepository +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Config(application = Application::class) +@RunWith(RobolectricTestRunner::class) +internal class AiMediaAnalysisSchedulingTest { + private lateinit var analysis: AiMediaAnalysis + private lateinit var mediaRepository: MediaRepository + private lateinit var repository: FakeRepository + private lateinit var updateMediaDatabase: UpdateMediaDatabase + private lateinit var workManager: WorkManager + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + workManager = WorkManager.getInstance(context) + repository = FakeRepository() + analysis = AiMediaAnalysisImpl( + repository = repository, + scheduler = AiMediaAnalysisSchedulerImpl(workManager = workManager), + ) + mediaRepository = mockk(relaxed = true) + updateMediaDatabase = UpdateMediaDatabase( + repository = mediaRepository, + aiMediaAnalysis = analysis, + ) + } + + @Test + fun databaseUpdates_scheduleAnalysisOnlyWhileUserConsentIsEnabled() { + runTest { + updateMediaDatabase() + + assertTrue(analysisWorkInfos().isEmpty()) + + analysis.setAnalysisEnabled(enabled = true) + + val enabledWorkInfos = analysisWorkInfos() + assertEquals( + 1, + enabledWorkInfos.count { workInfo -> + SearchIndexerUpdaterWorker.TAG in workInfo.tags + }, + ) + + updateMediaDatabase() + + assertEquals( + 1, + analysisWorkInfos().count { workInfo -> + SearchIndexerUpdaterWorker.TAG in workInfo.tags + }, + ) + + analysis.setAnalysisEnabled(enabled = false) + val knownWorkIds = analysisWorkInfos().map { workInfo -> workInfo.id }.toSet() + val generatedDataClearCount = repository.generatedDataClearCount + + updateMediaDatabase() + + assertEquals(knownWorkIds, analysisWorkInfos().map { workInfo -> workInfo.id }.toSet()) + assertEquals(generatedDataClearCount, repository.generatedDataClearCount) + coVerify(exactly = 3) { + mediaRepository.updateInternalDatabase() + } + } + } + + @Test + fun cancellingCategoryWork_keepsActiveIndexingScheduled() { + runTest { + repository.setAnalysisEnabled(enabled = true) + analysis.requestCategoryClassification() + analysis.requestAnalysis() + val originalIndexingIds = analysisWorkInfos().map { workInfo -> workInfo.id }.toSet() + + analysis.cancelCategoryClassification() + + val indexingWorkInfos = analysisWorkInfos() + assertTrue(indexingWorkInfos.any { workInfo -> + workInfo.id in originalIndexingIds && + (workInfo.state == WorkInfo.State.ENQUEUED || + workInfo.state == WorkInfo.State.RUNNING) + }) + } + } + + @Test + fun enablingCategories_replacesActiveIndexingWithForcedCategoryChain() { + runTest { + repository.setAnalysisEnabled(enabled = true) + repository.setCategoryClassificationEnabled(enabled = false) + analysis.requestAnalysis() + + analysis.setCategoryClassificationEnabled(enabled = true) + + val categoryWorkInfos = categoryWorkInfos() + assertEquals(1, categoryWorkInfos.size) + assertEquals(WorkInfo.State.BLOCKED, categoryWorkInfos.single().state) + assertTrue(categoryWorkInfos.single().forcesCategoryClassification()) + assertTrue(analysisWorkInfos().any { workInfo -> + workInfo.state == WorkInfo.State.ENQUEUED || + workInfo.state == WorkInfo.State.RUNNING + }) + } + } + + @Test + fun explicitCategoryRequest_replacesPassiveChainWithOneForcedCategoryChain() { + runTest { + repository.setAnalysisEnabled(enabled = true) + analysis.requestAnalysis() + + val queuedWorkState = analysis.workState.first { workState -> + workState.isAnalysisActive + } + assertTrue(queuedWorkState.isCategoryActive) + + analysis.requestCategoryClassification() + + val activeCategoryWorkInfos = categoryWorkInfos() + .filterNot { workInfo -> workInfo.state.isFinished } + assertEquals(1, activeCategoryWorkInfos.size) + assertEquals(WorkInfo.State.BLOCKED, activeCategoryWorkInfos.single().state) + assertTrue(activeCategoryWorkInfos.single().forcesCategoryClassification()) + assertEquals( + 1, + analysisWorkInfos().count { workInfo -> !workInfo.state.isFinished }, + ) + } + } + + private fun analysisWorkInfos(): List { + return workManager.getWorkInfosByTag(SearchIndexerUpdaterWorker.TAG).get() + } + + private fun categoryWorkInfos(): List { + return workManager.getWorkInfosByTag(CategoryWorker.TAG).get() + } + + @SuppressLint("RestrictedApi") + private fun WorkInfo.forcesCategoryClassification(): Boolean { + val workManagerImpl = workManager as WorkManagerImpl + val workSpec = workManagerImpl.workDatabase + .workSpecDao() + .getWorkSpec(id = id.toString()) + return workSpec?.input?.getBoolean(CategoryWorker.KEY_FORCE_CLASSIFICATION, false) == true + } + + private class FakeRepository : AiMediaAnalysisRepository { + private val preferencesFlow = MutableStateFlow( + AiMediaAnalysisPreferences( + analysisEnabled = false, + categoryClassificationEnabled = true, + analysisCleanupPending = false, + categoryCleanupPending = false, + ), + ) + + var generatedDataClearCount = 0 + private set + + override val preferences: Flow = preferencesFlow + + override suspend fun getPreferences(): AiMediaAnalysisPreferences { + return preferencesFlow.value + } + + override suspend fun needsMigration(): Boolean { + return false + } + + override suspend fun completeMigration() { + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + preferencesFlow.value = preferencesFlow.value.copy( + analysisEnabled = enabled, + analysisCleanupPending = !enabled, + categoryCleanupPending = !enabled, + ) + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + preferencesFlow.value = preferencesFlow.value.copy( + categoryClassificationEnabled = enabled, + categoryCleanupPending = !enabled, + ) + } + + override suspend fun completeAnalysisCleanup() { + preferencesFlow.value = preferencesFlow.value.copy( + analysisCleanupPending = false, + categoryCleanupPending = false, + ) + } + + override suspend fun completeCategoryCleanup() { + preferencesFlow.value = preferencesFlow.value.copy(categoryCleanupPending = false) + } + + override suspend fun invalidateGeneratedData(mediaIds: Set) { + } + + override suspend fun removeMediaData(mediaIds: Set) { + } + + override suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List { + return emptyList() + } + + override suspend fun clearAllGeneratedData() { + generatedDataClearCount++ + } + + override suspend fun clearCategoryGeneratedData() { + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisTest.kt new file mode 100644 index 0000000000..dfc2604f06 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/domain/use_case/AiMediaAnalysisTest.kt @@ -0,0 +1,439 @@ +package com.dot.gallery.feature_node.domain.use_case + +import com.dot.gallery.feature_node.data.model.AiMediaAnalysisPreferences +import com.dot.gallery.feature_node.data.repository.AiMediaAnalysisRepository +import com.dot.gallery.feature_node.domain.model.AiMediaAnalysisWorkState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class AiMediaAnalysisTest { + + @Test + fun initialize_migratesToDisabledAfterCancellingAndClearingGeneratedData() { + runTest { + val events = mutableListOf() + val repository = FakeRepository(needsMigration = true, events = events) + val scheduler = FakeScheduler(events = events) + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.initialize() + + assertFalse(repository.currentPreferences.analysisEnabled) + assertEquals( + listOf( + "set_analysis_false", + "cancel_all", + "complete_migration", + "schedule_cleanup", + ), + events, + ) + } + } + + @Test + fun enable_schedulesIndexAndCategories() { + runTest { + val repository = FakeRepository(needsMigration = false) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.setAnalysisEnabled(enabled = true) + + assertTrue(repository.currentPreferences.analysisEnabled) + assertEquals(listOf(true to true), scheduler.analysisRequests) + } + } + + @Test + fun disable_persistsConsentBeforeCancellingAndDeleting() { + runTest { + val events = mutableListOf() + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + events = events, + ) + val scheduler = FakeScheduler(events = events) + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.setAnalysisEnabled(enabled = false) + + assertEquals( + listOf("set_analysis_false", "cancel_all", "schedule_cleanup"), + events, + ) + assertTrue(repository.currentPreferences.analysisCleanupPending) + } + } + + @Test + fun disablingCategories_preservesSemanticEmbeddingsAndClearsCategoryData() { + runTest { + val events = mutableListOf() + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + events = events, + ) + val scheduler = FakeScheduler(events = events) + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.setCategoryClassificationEnabled(enabled = false) + + assertEquals( + listOf("set_categories_false", "cancel_categories", "schedule_cleanup"), + events, + ) + assertFalse(repository.allGeneratedDataCleared) + assertFalse(repository.categoryGeneratedDataCleared) + assertTrue(repository.currentPreferences.categoryCleanupPending) + assertTrue(scheduler.analysisRequests.isEmpty()) + } + } + + @Test + fun enablingCategories_schedulesForcedClassificationAfterFreshIndexing() { + runTest { + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences( + analysisEnabled = true, + categoryClassificationEnabled = false, + ), + ) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.setCategoryClassificationEnabled(enabled = true) + + assertEquals(1, scheduler.forcedCategoryAnalysisRequests) + assertTrue(scheduler.analysisRequests.isEmpty()) + } + } + + @Test + fun cancelCategories_doesNotReplaceIndependentIndexingWork() { + runTest { + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + ) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.cancelCategoryClassification() + + assertTrue(scheduler.analysisRequests.isEmpty()) + } + } + + @Test + fun initialize_reschedulesCleanupLeftPendingByCancelledSettingOperation() { + runTest { + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + ) + val cancellingScheduler = FakeScheduler(cancelCleanupScheduling = true) + val cancelledAnalysis = AiMediaAnalysisImpl( + repository = repository, + scheduler = cancellingScheduler, + ) + + runCatching { + cancelledAnalysis.setAnalysisEnabled(enabled = false) + } + + assertTrue(repository.currentPreferences.analysisCleanupPending) + + val recoveredScheduler = FakeScheduler() + val recreatedAnalysis = AiMediaAnalysisImpl( + repository = repository, + scheduler = recoveredScheduler, + ) + recreatedAnalysis.initialize() + + assertEquals(1, recoveredScheduler.cleanupRequests) + } + } + + @Test + fun disable_finishesDurableSchedulingAfterCallerIsCancelled() { + runTest { + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + ) + val cancellationStarted = CompletableDeferred() + val allowCancellationToFinish = CompletableDeferred() + val scheduler = FakeScheduler( + cancellationStarted = cancellationStarted, + allowCancellationToFinish = allowCancellationToFinish, + ) + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + val settingJob = launch { + analysis.setAnalysisEnabled(enabled = false) + } + cancellationStarted.await() + settingJob.cancel() + allowCancellationToFinish.complete(Unit) + settingJob.join() + + assertEquals(1, scheduler.cleanupRequests) + assertTrue(repository.currentPreferences.analysisCleanupPending) + } + } + + @Test + fun reenableAnalysis_preservesAndSchedulesPendingCategoryCleanup() { + runTest { + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences( + analysisEnabled = true, + categoryClassificationEnabled = true, + ), + ) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + analysis.setCategoryClassificationEnabled(enabled = false) + analysis.setAnalysisEnabled(enabled = false) + analysis.setAnalysisEnabled(enabled = true) + + assertTrue(repository.currentPreferences.analysisEnabled) + assertFalse(repository.currentPreferences.categoryClassificationEnabled) + assertFalse(repository.currentPreferences.analysisCleanupPending) + assertTrue(repository.currentPreferences.categoryCleanupPending) + assertEquals(3, scheduler.cleanupRequests) + assertEquals(1, scheduler.cancelCleanupRequests) + assertEquals( + listOf(false to true), + scheduler.analysisRequests, + ) + } + } + + @Test + fun disableAnalysis_schedulesCleanupWhenCancelledAtRepositoryReturnBoundary() { + runTest { + val settingPersisted = CompletableDeferred() + val allowRepositoryReturn = CompletableDeferred() + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + analysisSettingPersisted = settingPersisted, + allowAnalysisSettingReturn = allowRepositoryReturn, + ) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + val settingJob = launch { + analysis.setAnalysisEnabled(enabled = false) + } + settingPersisted.await() + settingJob.cancel() + allowRepositoryReturn.complete(Unit) + settingJob.join() + + assertTrue(repository.currentPreferences.analysisCleanupPending) + assertEquals(1, scheduler.cleanupRequests) + } + } + + @Test + fun disableCategories_schedulesCleanupWhenCancelledAtRepositoryReturnBoundary() { + runTest { + val settingPersisted = CompletableDeferred() + val allowRepositoryReturn = CompletableDeferred() + val repository = FakeRepository( + needsMigration = false, + initialPreferences = preferences(analysisEnabled = true), + categorySettingPersisted = settingPersisted, + allowCategorySettingReturn = allowRepositoryReturn, + ) + val scheduler = FakeScheduler() + val analysis = AiMediaAnalysisImpl(repository = repository, scheduler = scheduler) + + val settingJob = launch { + analysis.setCategoryClassificationEnabled(enabled = false) + } + settingPersisted.await() + settingJob.cancel() + allowRepositoryReturn.complete(Unit) + settingJob.join() + + assertTrue(repository.currentPreferences.categoryCleanupPending) + assertEquals(1, scheduler.cleanupRequests) + } + } + + private class FakeRepository( + private var needsMigration: Boolean, + initialPreferences: AiMediaAnalysisPreferences = preferences(), + private val events: MutableList = mutableListOf(), + private val analysisSettingPersisted: CompletableDeferred? = null, + private val allowAnalysisSettingReturn: CompletableDeferred? = null, + private val categorySettingPersisted: CompletableDeferred? = null, + private val allowCategorySettingReturn: CompletableDeferred? = null, + ) : AiMediaAnalysisRepository { + private val preferencesFlow = MutableStateFlow(initialPreferences) + var allGeneratedDataCleared = false + var categoryGeneratedDataCleared = false + + val currentPreferences: AiMediaAnalysisPreferences + get() = preferencesFlow.value + + override val preferences: Flow = preferencesFlow + + override suspend fun getPreferences(): AiMediaAnalysisPreferences { + return preferencesFlow.value + } + + override suspend fun needsMigration(): Boolean { + return needsMigration + } + + override suspend fun completeMigration() { + events += "complete_migration" + needsMigration = false + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + events += "set_analysis_$enabled" + val currentPreferences = preferencesFlow.value + preferencesFlow.value = when { + enabled -> currentPreferences.copy( + analysisEnabled = true, + analysisCleanupPending = false, + categoryCleanupPending = currentPreferences.categoryCleanupPending && + !currentPreferences.categoryClassificationEnabled, + ) + + else -> currentPreferences.copy( + analysisEnabled = false, + analysisCleanupPending = true, + categoryCleanupPending = true, + ) + } + analysisSettingPersisted?.complete(Unit) + allowAnalysisSettingReturn?.await() + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + events += "set_categories_$enabled" + preferencesFlow.value = preferencesFlow.value.copy( + categoryClassificationEnabled = enabled, + categoryCleanupPending = !enabled, + ) + categorySettingPersisted?.complete(Unit) + allowCategorySettingReturn?.await() + } + + override suspend fun completeAnalysisCleanup() { + val currentPreferences = preferencesFlow.value + preferencesFlow.value = currentPreferences.copy( + analysisCleanupPending = false, + categoryCleanupPending = currentPreferences.categoryCleanupPending && + !currentPreferences.categoryClassificationEnabled, + ) + } + + override suspend fun completeCategoryCleanup() { + preferencesFlow.value = preferencesFlow.value.copy(categoryCleanupPending = false) + } + + override suspend fun invalidateGeneratedData(mediaIds: Set) { + } + + override suspend fun removeMediaData(mediaIds: Set) { + } + + override suspend fun getClassifiedMediaIdPage(afterId: Long, limit: Int): List { + return emptyList() + } + + override suspend fun clearAllGeneratedData() { + events += "clear_all" + allGeneratedDataCleared = true + } + + override suspend fun clearCategoryGeneratedData() { + events += "clear_categories" + categoryGeneratedDataCleared = true + } + } + + private class FakeScheduler( + private val events: MutableList = mutableListOf(), + private val cancelCleanupScheduling: Boolean = false, + private val cancellationStarted: CompletableDeferred? = null, + private val allowCancellationToFinish: CompletableDeferred? = null, + ) : AiMediaAnalysisScheduler { + val analysisRequests = mutableListOf>() + var forcedCategoryAnalysisRequests = 0 + var cleanupRequests = 0 + var cancelCleanupRequests = 0 + + override val workState: Flow = emptyFlow() + + override suspend fun scheduleAnalysis( + includeCategories: Boolean, + replaceExisting: Boolean, + ) { + analysisRequests += includeCategories to replaceExisting + } + + override suspend fun scheduleAnalysisWithForcedCategoryClassification() { + forcedCategoryAnalysisRequests++ + } + + override suspend fun scheduleGeneratedDataCleanup() { + events += "schedule_cleanup" + if (cancelCleanupScheduling) { + throw kotlinx.coroutines.CancellationException("setting operation cancelled") + } + cleanupRequests++ + } + + override suspend fun cancelAll() { + events += "cancel_all" + cancellationStarted?.complete(Unit) + allowCancellationToFinish?.await() + } + + override suspend fun cancelCategoryClassification() { + events += "cancel_categories" + } + + override suspend fun cancelGeneratedDataCleanup() { + cancelCleanupRequests++ + } + } + + companion object { + private fun preferences( + analysisEnabled: Boolean = false, + categoryClassificationEnabled: Boolean = true, + analysisCleanupPending: Boolean = false, + categoryCleanupPending: Boolean = false, + ): AiMediaAnalysisPreferences { + return AiMediaAnalysisPreferences( + analysisEnabled = analysisEnabled, + categoryClassificationEnabled = categoryClassificationEnabled, + analysisCleanupPending = analysisCleanupPending, + categoryCleanupPending = categoryCleanupPending, + ) + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorerTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorerTest.kt new file mode 100644 index 0000000000..ecd70830cb --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/classifier/ImageEmbeddingPageScorerTest.kt @@ -0,0 +1,55 @@ +package com.dot.gallery.feature_node.presentation.classifier + +import com.dot.gallery.feature_node.data.model.ImageEmbedding +import com.dot.gallery.feature_node.data.repository.MediaRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ImageEmbeddingPageScorerTest { + + @Test + fun scoreImageEmbeddingPages_readsAndScoresBoundedPages() { + runTest { + val repository = mockk() + coEvery { + repository.getImageEmbeddingPage(afterId = Long.MIN_VALUE, limit = PAGE_SIZE) + } returns listOf( + embedding(id = 1L, score = 0.2f), + embedding(id = 2L, score = 0.8f), + ) + coEvery { + repository.getImageEmbeddingPage(afterId = 2L, limit = PAGE_SIZE) + } returns listOf(embedding(id = 3L, score = 0.5f)) + coEvery { + repository.getImageEmbeddingPage(afterId = 3L, limit = PAGE_SIZE) + } returns emptyList() + + val scores = scoreImageEmbeddingPages(repository = repository) { imageEmbedding -> + imageEmbedding.embedding.single() + } + + assertEquals(listOf(2L, 3L, 1L), scores.map { (mediaId, _) -> mediaId }) + coVerify(exactly = 1) { + repository.getImageEmbeddingPage(afterId = Long.MIN_VALUE, limit = PAGE_SIZE) + repository.getImageEmbeddingPage(afterId = 2L, limit = PAGE_SIZE) + repository.getImageEmbeddingPage(afterId = 3L, limit = PAGE_SIZE) + } + } + } + + private fun embedding(id: Long, score: Float): ImageEmbedding { + return ImageEmbedding( + id = id, + date = 1L, + embedding = floatArrayOf(score), + ) + } + + private companion object { + private const val PAGE_SIZE = 256 + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModelTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModelTest.kt new file mode 100644 index 0000000000..93be1eb87e --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/edit/crop/CropViewModelTest.kt @@ -0,0 +1,636 @@ +package com.dot.gallery.feature_node.presentation.edit.crop + +import android.content.Intent +import android.content.IntentSender +import android.graphics.Bitmap +import android.graphics.RectF +import android.net.Uri +import com.dot.gallery.feature_node.data.model.editor.crop.CropImage +import com.dot.gallery.feature_node.data.model.editor.crop.ExternalCropRequest +import com.dot.gallery.feature_node.data.model.editor.crop.NormalizedCropRect +import com.dot.gallery.feature_node.data.repository.ExternalCropRepository +import com.dot.gallery.feature_node.data.repository.ExternalCropSaveResult +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CropViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun launchRequest_loadsImageIntoUiState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val image = cropImage() + val repository = mockExternalCropRepository(imageToLoad = image) + val request = externalCropRequest() + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + assertSame(image, viewModel.uiState.value.image) + assertEquals(1f, viewModel.uiState.value.aspectRatio) + coVerify(exactly = 1) { + repository.loadImage(uri = request.sourceUri) + } + } + } + + @Test + fun duplicateLaunchRequest_doesNotReloadImage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository() + val viewModel = createViewModel(repository = repository) + val request = externalCropRequest() + + viewModel.onLaunchRequest(request = request) + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + coVerify(exactly = 1) { + repository.loadImage(uri = request.sourceUri) + } + } + } + + @Test + fun launchRequest_withWideImage_initializesCenteredAspectCropRect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository( + imageToLoad = cropImage( + width = 512, + height = 256, + ), + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + assertCropRect( + expected = NormalizedCropRect( + left = 0.25f, + top = 0f, + right = 0.75f, + bottom = 1f, + ), + actual = requireNotNull(viewModel.uiState.value.normalizedCropRect), + ) + } + } + + @Test + fun launchRequest_withTallImage_initializesCenteredAspectCropRect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository( + imageToLoad = cropImage( + width = 256, + height = 512, + ), + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + assertCropRect( + expected = NormalizedCropRect( + left = 0f, + top = 0.25f, + right = 1f, + bottom = 0.75f, + ), + actual = requireNotNull(viewModel.uiState.value.normalizedCropRect), + ) + } + } + + @Test + fun launchRequest_withoutAspectRatio_initializesFullImageCropRect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository( + imageToLoad = cropImage( + width = 512, + height = 256, + ), + ) + val viewModel = createLoadedViewModel( + repository = repository, + request = externalCropRequest( + outputX = 0, + outputY = 0, + aspectX = 0, + aspectY = 0, + ), + ) + advanceUntilIdle() + + assertCropRect( + expected = NormalizedCropRect.full(), + actual = requireNotNull(viewModel.uiState.value.normalizedCropRect), + ) + } + } + + @Test + fun loadFailure_emitsCanceledEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository(imageToLoad = null) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(request = externalCropRequest()) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.image) + assertEquals(CropEffect.FinishCanceled, awaitEffect(viewModel = viewModel)) + } + } + + @Test + fun doneClick_beforeImageLoaded_doesNotSave() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository() + val viewModel = createViewModel(repository = repository) + + viewModel.onDoneClick() + + coVerify(exactly = 0) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + assertNull(nextEffectOrNull(viewModel = viewModel)) + } + } + + @Test + fun doneClick_withoutUserChange_savesInitialCropRect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val resultIntent = Intent() + val image = cropImage( + width = 512, + height = 256, + ) + val savedRect = CapturingSlot() + val repository = mockExternalCropRepository( + imageToLoad = image, + saveResult = ExternalCropSaveResult.Saved(resultIntent = resultIntent), + savedRect = savedRect, + ) + val request = externalCropRequest() + val viewModel = createLoadedViewModel( + repository = repository, + request = request, + ) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + + coVerify(exactly = 1) { + repository.saveCropResult( + request = request, + image = image, + normalizedRect = any(), + ) + } + assertCropRect( + expected = NormalizedCropRect( + left = 0.25f, + top = 0f, + right = 0.75f, + bottom = 1f, + ), + actual = savedRect.captured, + ) + + val effect = awaitEffect(viewModel = viewModel) + assertTrue(effect is CropEffect.FinishWithResult) + assertSame(resultIntent, (effect as CropEffect.FinishWithResult).resultIntent) + assertEquals(false, viewModel.uiState.value.isSaving) + } + } + + @Test + fun doneClick_afterUserChange_savesChangedCropRect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val savedRect = CapturingSlot() + val repository = mockExternalCropRepository(savedRect = savedRect) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + val normalizedRect = testCropRect() + viewModel.onCropRectChanged(normalizedRect = normalizedRect) + viewModel.onDoneClick() + advanceUntilIdle() + + assertSame(normalizedRect, savedRect.captured) + } + } + + @Test + fun doneClick_whileSaving_savesOnce() { + runTest(context = mainDispatcherRule.testDispatcher) { + val saveResult = CompletableDeferred() + val repository = mockExternalCropRepository(saveResultDeferred = saveResult) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + viewModel.onDoneClick() + advanceUntilIdle() + + assertEquals(true, viewModel.uiState.value.isSaving) + coVerify(exactly = 1) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + + saveResult.complete(ExternalCropSaveResult.Saved(resultIntent = Intent())) + advanceUntilIdle() + + assertTrue(awaitEffect(viewModel = viewModel) is CropEffect.FinishWithResult) + coVerify(exactly = 1) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + } + } + + @Test + fun cropRectChanged_updatesUiStateWithCopy() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository() + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + val androidRect = RectF(0.1f, 0.2f, 0.7f, 0.8f) + val normalizedRect = NormalizedCropRect(rect = androidRect) + viewModel.onCropRectChanged(normalizedRect = normalizedRect) + androidRect.left = 0.4f + + val stateRect = requireNotNull(viewModel.uiState.value.normalizedCropRect) + assertEquals(0.1f, stateRect.left, 0f) + assertEquals(0.2f, stateRect.top, 0f) + assertEquals(0.7f, stateRect.right, 0f) + assertEquals(0.8f, stateRect.bottom, 0f) + } + } + + @Test + fun doneClick_whenSaveFails_emitsSaveErrorEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository(saveResult = ExternalCropSaveResult.Failed) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + + assertEquals(CropEffect.ShowSaveErrorAndCancel, awaitEffect(viewModel = viewModel)) + assertEquals(false, viewModel.uiState.value.isSaving) + coVerify(exactly = 1) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + } + } + + @Test + fun doneClick_whenOutputPermissionRequired_requestsItAndSavesAfterItIsGranted() { + runTest(context = mainDispatcherRule.testDispatcher) { + val intentSender = mockk() + val resultIntent = Intent() + val repository = mockExternalCropRepository( + saveResults = listOf( + ExternalCropSaveResult.OutputPermissionRequired(intentSender = intentSender), + ExternalCropSaveResult.Saved(resultIntent = resultIntent), + ), + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + + assertEquals( + CropEffect.RequestOutputWritePermission(intentSender = intentSender), + awaitEffect(viewModel = viewModel), + ) + + viewModel.onOutputWritePermissionResult(isGranted = true) + advanceUntilIdle() + + assertEquals( + CropEffect.FinishWithResult(resultIntent = resultIntent), + awaitEffect(viewModel = viewModel), + ) + coVerify(exactly = 2) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + } + } + + @Test + fun outputWritePermissionResult_whenDenied_finishesCanceledWithoutRetrying() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository( + saveResult = ExternalCropSaveResult.OutputPermissionRequired( + intentSender = mockk(), + ), + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + assertTrue(awaitEffect(viewModel = viewModel) is CropEffect.RequestOutputWritePermission) + + viewModel.onOutputWritePermissionResult(isGranted = false) + advanceUntilIdle() + + assertEquals(CropEffect.FinishCanceled, awaitEffect(viewModel = viewModel)) + coVerify(exactly = 1) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + } + } + + /** Asking again after the user already answered would loop the system dialog. */ + @Test + fun outputWritePermissionResult_whenStillDeniedAfterGrant_failsInsteadOfAskingAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository( + saveResult = ExternalCropSaveResult.OutputPermissionRequired( + intentSender = mockk(), + ), + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + assertTrue(awaitEffect(viewModel = viewModel) is CropEffect.RequestOutputWritePermission) + + viewModel.onOutputWritePermissionResult(isGranted = true) + advanceUntilIdle() + + assertEquals(CropEffect.ShowSaveErrorAndCancel, awaitEffect(viewModel = viewModel)) + } + } + + @Test + fun cancelClick_whileSaving_cancelsSaveAndFinishes() { + runTest(context = mainDispatcherRule.testDispatcher) { + val saveResult = CompletableDeferred() + var saveWasCancelled = false + val repository = mockExternalCropRepository( + saveResultDeferred = saveResult, + onSaveCancelled = { + saveWasCancelled = true + }, + ) + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + + assertEquals(true, viewModel.uiState.value.isSaving) + viewModel.onCancelClick() + advanceUntilIdle() + + assertEquals(CropEffect.FinishCanceled, awaitEffect(viewModel = viewModel)) + assertEquals(false, viewModel.uiState.value.isSaving) + assertEquals(true, saveWasCancelled) + } + } + + @Test + fun doneClick_afterFinish_doesNotSaveAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockExternalCropRepository() + val viewModel = createLoadedViewModel(repository = repository) + advanceUntilIdle() + + viewModel.onDoneClick() + advanceUntilIdle() + awaitEffect(viewModel = viewModel) + + viewModel.onDoneClick() + advanceUntilIdle() + + coVerify(exactly = 1) { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } + } + } + + private fun createLoadedViewModel( + repository: ExternalCropRepository, + request: ExternalCropRequest = externalCropRequest(), + ): CropViewModel { + val viewModel = createViewModel(repository = repository) + viewModel.onLaunchRequest(request = request) + return viewModel + } + + private fun createViewModel(repository: ExternalCropRepository): CropViewModel { + return CropViewModel(repository = repository) + } + + private suspend fun awaitEffect(viewModel: CropViewModel): CropEffect { + return withTimeout(timeMillis = 1_000L) { + viewModel.effects.first() + } + } + + private suspend fun nextEffectOrNull(viewModel: CropViewModel): CropEffect? { + return withTimeoutOrNull(timeMillis = 100L) { + viewModel.effects.first() + } + } +} + +private fun mockExternalCropRepository( + imageToLoad: CropImage? = cropImage(), + saveResult: ExternalCropSaveResult = ExternalCropSaveResult.Saved(resultIntent = Intent()), + saveResultDeferred: CompletableDeferred? = null, + /** One result per save attempt, for the write-permission retry. */ + saveResults: List? = null, + savedRect: CapturingSlot? = null, + onSaveCancelled: () -> Unit = {}, +): ExternalCropRepository { + val repository = mockk() + coEvery { + repository.loadImage(uri = any()) + } returns imageToLoad + if (saveResults != null) { + coEvery { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } returnsMany saveResults + return repository + } + configureSaveResult( + repository = repository, + saveResult = saveResult, + saveResultDeferred = saveResultDeferred, + savedRect = savedRect, + onSaveCancelled = onSaveCancelled, + ) + return repository +} + +private fun configureSaveResult( + repository: ExternalCropRepository, + saveResult: ExternalCropSaveResult, + saveResultDeferred: CompletableDeferred?, + savedRect: CapturingSlot?, + onSaveCancelled: () -> Unit, +) { + when (savedRect) { + null -> { + coEvery { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = any(), + ) + } coAnswers { + awaitSaveResult( + saveResult = saveResult, + saveResultDeferred = saveResultDeferred, + onSaveCancelled = onSaveCancelled, + ) + } + } + + else -> { + coEvery { + repository.saveCropResult( + request = any(), + image = any(), + normalizedRect = capture(savedRect), + ) + } coAnswers { + awaitSaveResult( + saveResult = saveResult, + saveResultDeferred = saveResultDeferred, + onSaveCancelled = onSaveCancelled, + ) + } + } + } +} + +private suspend fun awaitSaveResult( + saveResult: ExternalCropSaveResult, + saveResultDeferred: CompletableDeferred?, + onSaveCancelled: () -> Unit, +): ExternalCropSaveResult { + return try { + when (saveResultDeferred) { + null -> saveResult + else -> saveResultDeferred.await() + } + } catch (exception: CancellationException) { + onSaveCancelled() + throw exception + } +} + +private fun externalCropRequest( + outputX: Int = 128, + outputY: Int = 128, + aspectX: Int = 1, + aspectY: Int = 1, +): ExternalCropRequest { + return ExternalCropRequest( + sourceUri = Uri.parse("content://test/source"), + outputUri = null, + outputX = outputX, + outputY = outputY, + scale = true, + scaleUpIfNeeded = true, + aspectX = aspectX, + aspectY = aspectY, + returnData = false, + outputFormat = null, + ) +} + +private fun cropImage(width: Int = 256, height: Int = 256): CropImage { + val sourceBitmap = Bitmap.createBitmap( + width, + height, + Bitmap.Config.ARGB_8888, + ) + return CropImage( + sourceBitmap = sourceBitmap, + previewBitmap = sourceBitmap, + ) +} + +private fun testCropRect(): NormalizedCropRect { + return NormalizedCropRect( + left = 0.1f, + top = 0.2f, + right = 0.7f, + bottom = 0.8f, + ) +} + +private fun assertCropRect(expected: NormalizedCropRect, actual: NormalizedCropRect) { + assertEquals(expected.left, actual.left, CROP_RECT_TOLERANCE) + assertEquals(expected.top, actual.top, CROP_RECT_TOLERANCE) + assertEquals(expected.right, actual.right, CROP_RECT_TOLERANCE) + assertEquals(expected.bottom, actual.bottom, CROP_RECT_TOLERANCE) +} + +private const val CROP_RECT_TOLERANCE = 0.0001f diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModelTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModelTest.kt new file mode 100644 index 0000000000..4ea9073ac9 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaViewModelTest.kt @@ -0,0 +1,432 @@ +package com.dot.gallery.feature_node.presentation.exif + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.dot.gallery.core.workers.MediaCopyBatch +import com.dot.gallery.core.workers.MediaCopyBatchStatus +import com.dot.gallery.core.workers.MediaCopyRequest +import com.dot.gallery.core.workers.MediaCopyScheduler +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CopyMediaViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun noRestoredBatch_startsInSelectingState() { + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = FakeMediaCopyScheduler(), + savedStateHandle = SavedStateHandle(), + ) + + assertEquals(CopyMediaUiState.Selecting, viewModel.uiState.value) + } + + @Test + fun restoredBatch_withRunningWork_exposesProgress() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow(MediaCopyBatchStatus.Copying(progress = 0.5f)), + ) + val viewModel = restoredViewModel(scheduler = scheduler) + + runCurrent() + + assertEquals(CopyMediaUiState.Copying(progress = 0.5f), viewModel.uiState.value) + } + } + + @Test + fun restoredBatch_withSuccessfulResult_exposesSuccess() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow( + MediaCopyBatchStatus.Finished( + copiedCount = 3, + failedCount = 0, + successful = true, + ), + ), + ) + val viewModel = restoredViewModel(scheduler = scheduler) + + advanceUntilIdle() + + assertEquals(CopyMediaUiState.Succeeded, viewModel.uiState.value) + } + } + + @Test + fun restoredBatch_withMixedResult_exposesCopiedAndFailedCounts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow( + MediaCopyBatchStatus.Finished( + copiedCount = 2, + failedCount = 1, + successful = false, + ), + ), + ) + val viewModel = restoredViewModel(scheduler = scheduler) + + advanceUntilIdle() + + assertEquals( + CopyMediaUiState.Failed(copiedCount = 2, failedCount = 1), + viewModel.uiState.value, + ) + } + } + + @Test + fun restoredBatch_withUnavailableStatus_exposesDismissibleUnavailableState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow(MediaCopyBatchStatus.Unavailable), + ) + val viewModel = restoredViewModel(scheduler = scheduler) + + advanceUntilIdle() + + assertEquals(CopyMediaUiState.StatusUnavailable, viewModel.uiState.value) + } + } + + @Test + fun restoredBatch_whenObservationFails_exposesDismissibleUnavailableState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = flow { throw IllegalStateException("query failed") }, + ) + val viewModel = restoredViewModel(scheduler = scheduler) + + advanceUntilIdle() + + assertEquals(CopyMediaUiState.StatusUnavailable, viewModel.uiState.value) + } + } + + @Test + fun enqueueCopy_savesBatchBeforeEnqueueCompletes() { + runTest(context = mainDispatcherRule.testDispatcher) { + val enqueueGate = CompletableDeferred() + val scheduler = FakeMediaCopyScheduler( + enqueueGate = enqueueGate, + statuses = MutableStateFlow(MediaCopyBatchStatus.Copying(progress = 0f)), + ) + val savedStateHandle = SavedStateHandle() + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = savedStateHandle, + ) + + viewModel.enqueueCopy(media() to DESTINATION_PATH) + runCurrent() + + assertEquals(CopyMediaUiState.Copying(progress = 0f), viewModel.uiState.value) + assertEquals(BATCH_TAG, savedStateHandle.get(BATCH_TAG_KEY)) + assertEquals(1, savedStateHandle.get(BATCH_WORK_COUNT_KEY)) + assertEquals(3, savedStateHandle.get(BATCH_ITEM_COUNT_KEY)) + + enqueueGate.complete(Unit) + runCurrent() + + assertEquals(1, scheduler.prepareCount) + assertEquals(1, scheduler.enqueueCount) + } + } + + @Test + fun enqueueCopy_whenSchedulerFails_exposesUnavailableWithoutDiscardingBatch() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + enqueueFailure = IllegalStateException("enqueue failed"), + ) + val savedStateHandle = SavedStateHandle() + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = savedStateHandle, + ) + + viewModel.enqueueCopy( + media(id = 1L) to DESTINATION_PATH, + media(id = 2L) to DESTINATION_PATH, + ) + advanceUntilIdle() + + assertEquals( + CopyMediaUiState.StatusUnavailable, + viewModel.uiState.value, + ) + assertEquals(BATCH_TAG, savedStateHandle.get(BATCH_TAG_KEY)) + } + } + + @Test + fun enqueueCopy_whenWorkFinished_exposesActualCounts() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow( + MediaCopyBatchStatus.Finished( + copiedCount = 2, + failedCount = 1, + successful = false, + ), + ), + ) + val savedStateHandle = SavedStateHandle() + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = savedStateHandle, + ) + + viewModel.enqueueCopy( + media(id = 1L) to DESTINATION_PATH, + media(id = 2L) to DESTINATION_PATH, + media(id = 3L) to DESTINATION_PATH, + ) + advanceUntilIdle() + + assertEquals( + CopyMediaUiState.Failed(copiedCount = 2, failedCount = 1), + viewModel.uiState.value, + ) + } + } + + @Test + fun enqueueCopy_whenWorkIsActive_observesUntilActualTerminalResult() { + runTest(context = mainDispatcherRule.testDispatcher) { + val statuses = MutableStateFlow( + MediaCopyBatchStatus.Copying(progress = 0.5f), + ) + val scheduler = FakeMediaCopyScheduler( + statuses = statuses, + ) + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = SavedStateHandle(), + ) + + viewModel.enqueueCopy(media() to DESTINATION_PATH) + runCurrent() + + assertEquals(CopyMediaUiState.Copying(progress = 0.5f), viewModel.uiState.value) + + viewModel.enqueueCopy(media(id = 2L) to DESTINATION_PATH) + + assertEquals(1, scheduler.enqueueCount) + + statuses.value = MediaCopyBatchStatus.Finished( + copiedCount = 1, + failedCount = 0, + successful = true, + ) + advanceUntilIdle() + + assertEquals(CopyMediaUiState.Succeeded, viewModel.uiState.value) + } + } + + @Test + fun enqueueCopy_whenWorkInfoIsMissing_exposesUnavailable() { + runTest(context = mainDispatcherRule.testDispatcher) { + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow(MediaCopyBatchStatus.Unavailable), + ) + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = SavedStateHandle(), + ) + + viewModel.enqueueCopy(media() to DESTINATION_PATH) + advanceUntilIdle() + + assertEquals(CopyMediaUiState.StatusUnavailable, viewModel.uiState.value) + } + } + + @Test + fun processRecreation_whileEnqueueIsPending_restoresActiveBatchAndBlocksSecondEnqueue() { + runTest(context = mainDispatcherRule.testDispatcher) { + val enqueueGate = CompletableDeferred() + val scheduler = FakeMediaCopyScheduler( + enqueueGate = enqueueGate, + statuses = MutableStateFlow(MediaCopyBatchStatus.Copying(progress = 0.5f)), + ) + val savedStateHandle = SavedStateHandle() + val originalViewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = savedStateHandle, + ) + + originalViewModel.enqueueCopy(media() to DESTINATION_PATH) + runCurrent() + + val restoredViewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = snapshotSavedStateHandle(source = savedStateHandle), + ) + runCurrent() + + assertEquals( + CopyMediaUiState.Copying(progress = 0.5f), + restoredViewModel.uiState.value, + ) + + restoredViewModel.enqueueCopy(media(id = 2L) to DESTINATION_PATH) + + assertEquals(1, scheduler.enqueueCount) + + enqueueGate.complete(Unit) + runCurrent() + } + } + + @Test + fun enqueueCopy_whileEnqueueIsPending_doesNotEnqueueAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val enqueueGate = CompletableDeferred() + val scheduler = FakeMediaCopyScheduler(enqueueGate = enqueueGate) + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = SavedStateHandle(), + ) + + viewModel.enqueueCopy(media(id = 1L) to DESTINATION_PATH) + viewModel.enqueueCopy(media(id = 2L) to DESTINATION_PATH) + runCurrent() + + assertEquals(1, scheduler.enqueueCount) + + enqueueGate.complete(Unit) + runCurrent() + } + } + + @Test + fun resultHandled_clearsRestoredBatchAndReturnsToSelection() { + runTest(context = mainDispatcherRule.testDispatcher) { + val savedStateHandle = restoredSavedStateHandle( + legacyEnqueueConfirmed = true, + ) + val scheduler = FakeMediaCopyScheduler( + statuses = MutableStateFlow(MediaCopyBatchStatus.Unavailable), + ) + val viewModel = CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = savedStateHandle, + ) + advanceUntilIdle() + + viewModel.onResultHandled() + + assertEquals(CopyMediaUiState.Selecting, viewModel.uiState.value) + assertEquals(null, savedStateHandle.get(BATCH_TAG_KEY)) + assertEquals(null, savedStateHandle.get(LEGACY_BATCH_ENQUEUE_CONFIRMED_KEY)) + } + } + + private fun restoredViewModel(scheduler: MediaCopyScheduler): CopyMediaViewModel { + return CopyMediaViewModel( + mediaCopyScheduler = scheduler, + savedStateHandle = restoredSavedStateHandle(), + ) + } + + private fun restoredSavedStateHandle( + legacyEnqueueConfirmed: Boolean? = null, + ): SavedStateHandle { + val savedState = mutableMapOf( + BATCH_TAG_KEY to BATCH_TAG, + BATCH_WORK_COUNT_KEY to 1, + BATCH_ITEM_COUNT_KEY to 3, + ) + legacyEnqueueConfirmed?.let { confirmed -> + savedState[LEGACY_BATCH_ENQUEUE_CONFIRMED_KEY] = confirmed + } + return SavedStateHandle(savedState) + } + + private fun snapshotSavedStateHandle(source: SavedStateHandle): SavedStateHandle { + return SavedStateHandle( + mapOf( + BATCH_TAG_KEY to requireNotNull(source.get(BATCH_TAG_KEY)), + BATCH_WORK_COUNT_KEY to requireNotNull(source.get(BATCH_WORK_COUNT_KEY)), + BATCH_ITEM_COUNT_KEY to requireNotNull(source.get(BATCH_ITEM_COUNT_KEY)), + ), + ) + } + + private fun media(id: Long = 1L): Media.UriMedia { + return mockk().also { media -> + every { media.uri } returns mockk(name = "media-$id") + } + } + + private class FakeMediaCopyScheduler( + private val enqueueGate: CompletableDeferred? = null, + private val enqueueFailure: Exception? = null, + private val statuses: Flow = MutableStateFlow( + MediaCopyBatchStatus.Copying(progress = 0f), + ), + ) : MediaCopyScheduler { + + var prepareCount = 0 + private set + + var enqueueCount = 0 + private set + + override fun prepareBatch(requests: List): MediaCopyBatch { + prepareCount++ + return BATCH + } + + override suspend fun enqueue( + batch: MediaCopyBatch, + requests: List, + ) { + enqueueCount++ + enqueueGate?.await() + enqueueFailure?.let { exception -> throw exception } + } + + override fun observe(batch: MediaCopyBatch): Flow { + return statuses + } + } + + companion object { + private const val BATCH_ITEM_COUNT_KEY = "current_copy_batch_item_count" + private const val BATCH_TAG = "MediaCopyBatch_test" + private const val BATCH_TAG_KEY = "current_copy_batch_tag" + private const val BATCH_WORK_COUNT_KEY = "current_copy_batch_work_count" + private const val DESTINATION_PATH = "Pictures/Test" + private const val LEGACY_BATCH_ENQUEUE_CONFIRMED_KEY = + "current_copy_batch_enqueue_confirmed" + + private val BATCH = MediaCopyBatch( + tag = BATCH_TAG, + workRequestCount = 1, + itemCount = 3, + ) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModelTest.kt new file mode 100644 index 0000000000..b5c9fbac81 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/SearchViewModelTest.kt @@ -0,0 +1,324 @@ +package com.dot.gallery.feature_node.presentation.search + +import android.content.Context +import android.graphics.Bitmap +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.dot.gallery.core.MediaDistributor +import com.dot.gallery.core.ml.ManagedOrtSession +import com.dot.gallery.core.ml.ModelManager +import com.dot.gallery.core.ml.ModelStatus +import com.dot.gallery.core.sandbox.MediaPreviewDecoder +import com.dot.gallery.feature_node.data.model.CategoryWithMediaCount +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.repository.MediaRepository +import com.dot.gallery.feature_node.domain.model.AiMediaAnalysisWorkState +import com.dot.gallery.feature_node.domain.model.MediaMetadataState +import com.dot.gallery.feature_node.domain.model.MediaState +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysis +import com.dot.gallery.feature_node.domain.use_case.AiMediaAnalysisSettings +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SearchViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun disablingAnalysisWhileTextInferenceIsSuspended_cancelsAndClearsSemanticSearch() { + runTest(context = mainDispatcherRule.testDispatcher) { + val analysis = FakeAiMediaAnalysis( + initialSettings = AiMediaAnalysisSettings( + analysisEnabled = true, + categoryClassificationEnabled = true, + ), + ) + val inferenceStarted = CompletableDeferred() + val inferenceCancelled = CompletableDeferred() + val searchHelper = SuspendingSearchHelper( + inferenceStarted = inferenceStarted, + inferenceCancelled = inferenceCancelled, + ) + val viewModel = createViewModel( + analysis = analysis, + searchHelper = searchHelper, + ) + runCurrent() + + viewModel.setQuery(query = "mountain") + runCurrent() + inferenceStarted.await() + + analysis.settingsFlow.value = analysis.settingsFlow.value.copy( + analysisEnabled = false, + ) + runCurrent() + inferenceCancelled.await() + + assertEquals(SearchResultsState(), viewModel.searchResultsState.value) + assertEquals(0, searchHelper.sortCallCount) + } + } + + @Test + fun disablingCategories_hidesStoredCategories() { + runTest(context = mainDispatcherRule.testDispatcher) { + val analysis = FakeAiMediaAnalysis( + initialSettings = AiMediaAnalysisSettings( + analysisEnabled = true, + categoryClassificationEnabled = true, + ), + ) + val categories = MutableStateFlow(listOf(categoryWithMediaCount())) + val viewModel = createViewModel( + analysis = analysis, + categories = categories, + ) + + assertEquals(1, viewModel.topCategories.first { items -> items.isNotEmpty() }.size) + + analysis.settingsFlow.value = analysis.settingsFlow.value.copy( + categoryClassificationEnabled = false, + ) + + assertTrue(viewModel.topCategories.first { items -> items.isEmpty() }.isEmpty()) + } + } + + /** + * The search field owns its own text, so text that came from the field must never be echoed + * back to it. An echo lags the keystrokes by however long the collection takes, and a stale + * write landing mid-composition desynchronizes the ime composing region. + */ + @Test + fun setQueryFromUser_doesNotAskTheFieldToRewriteItself() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel(analysis = analysisDisabled()) + val overrides = viewModel.recordOverrides(backgroundScope) + runCurrent() + + viewModel.setQuery(query = "moun", apply = false, fromUser = true) + viewModel.setQuery(query = "mount", apply = false, fromUser = true) + runCurrent() + + assertEquals(emptyList(), overrides) + assertEquals("mount", viewModel.query.value) + } + } + + @Test + fun programmaticQueryWrites_askTheFieldToRewriteItself() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel(analysis = analysisDisabled()) + val overrides = viewModel.recordOverrides(backgroundScope) + runCurrent() + + viewModel.setMimeTypeQuery(mimeType = "image/*", hideExplicitQuery = true) + viewModel.clearQuery() + runCurrent() + + assertEquals(listOf("Images", ""), overrides) + assertEquals("", viewModel.query.value) + } + } + + /** + * A history tap is text the field does not have yet, so it has to be told — even though it goes + * through the same [SearchViewModel.setQuery] entry point the field itself uses. + */ + @Test + fun setQueryFromHistory_asksTheFieldToRewriteItself() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel(analysis = analysisDisabled()) + val overrides = viewModel.recordOverrides(backgroundScope) + runCurrent() + + viewModel.setQuery(query = "mountain") + runCurrent() + + assertEquals(listOf("mountain"), overrides) + } + } + + /** + * [SearchViewModel.query] has to be visible before the search job starts, so a keystroke that + * cancels its predecessor cannot drop the query that predecessor published. + */ + @Test + fun setQuery_publishesTheQueryBeforeTheSearchRuns() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel(analysis = analysisDisabled()) + + viewModel.setQuery(query = "mountain", apply = true, fromUser = true) + + assertEquals("mountain", viewModel.query.value) + } + } + + private fun SearchViewModel.recordOverrides(scope: CoroutineScope): List { + val recorded = mutableListOf() + scope.launch { queryOverrides.collect { recorded += it } } + return recorded + } + + private fun analysisDisabled(): FakeAiMediaAnalysis { + return FakeAiMediaAnalysis( + initialSettings = AiMediaAnalysisSettings( + analysisEnabled = false, + categoryClassificationEnabled = false, + ), + ) + } + + private fun createViewModel( + analysis: FakeAiMediaAnalysis, + searchHelper: SearchHelper = mockk { + every { isAvailable } returns false + }, + categories: Flow> = flowOf(emptyList()), + ): SearchViewModel { + val timelineMedia = MutableSharedFlow>(replay = 1).apply { + tryEmit(MediaState(isLoading = false)) + } + val mediaDistributor = mockk(relaxed = true) { + every { dateFormatsFlow } returns MutableStateFlow(Triple("", "", "")) + every { metadataFlow } returns flowOf(MediaMetadataState()) + every { timelineMediaFlow } returns timelineMedia + } + val repository = mockk(relaxed = true) { + every { getTopCategories(limit = any()) } returns categories + } + val workManager = mockk { + every { getWorkInfosByTagFlow(any()) } returns flowOf(emptyList()) + } + val modelManager = mockk { + every { status } returns MutableStateFlow(ModelStatus.READY) + every { isReady } returns true + } + val context = mockk(relaxed = true) + + return SearchViewModel( + mediaDistributor = mediaDistributor, + workManager = workManager, + searchHelper = searchHelper, + repository = repository, + modelManager = modelManager, + aiMediaAnalysis = analysis, + previewDecoder = mockk(relaxed = true), + ioDispatcher = mainDispatcherRule.testDispatcher, + context = context, + ) + } + + private fun categoryWithMediaCount(): CategoryWithMediaCount { + return CategoryWithMediaCount( + id = 1L, + name = "Nature", + searchTerms = "nature", + embedding = null, + referenceImageIds = emptyList(), + threshold = 0.2f, + isUserCreated = false, + isPinned = false, + createdAt = 0L, + updatedAt = 0L, + mediaCount = 1, + thumbnailMediaId = null, + ) + } + + private class SuspendingSearchHelper( + private val inferenceStarted: CompletableDeferred, + private val inferenceCancelled: CompletableDeferred, + ) : SearchHelper { + var sortCallCount = 0 + private set + + override val isAvailable: Boolean = true + + override fun sortByCosineDistance( + searchEmbedding: FloatArray, + imageEmbeddingsList: List, + imageIdxList: List, + ): List> { + sortCallCount++ + return emptyList() + } + + override suspend fun getTextEmbedding( + session: ManagedOrtSession, + text: String, + ): FloatArray { + inferenceStarted.complete(Unit) + try { + CompletableDeferred().await() + } finally { + inferenceCancelled.complete(Unit) + } + return floatArrayOf() + } + + override fun setupTextSession(): ManagedOrtSession { + return mockk(relaxed = true) + } + + override fun setupVisionSession(): ManagedOrtSession { + return mockk(relaxed = true) + } + + override suspend fun getImageEmbedding( + session: ManagedOrtSession, + bitmap: Bitmap, + ): FloatArray { + return floatArrayOf() + } + } + + private class FakeAiMediaAnalysis( + initialSettings: AiMediaAnalysisSettings, + ) : AiMediaAnalysis { + val settingsFlow = MutableStateFlow(initialSettings) + + override val settings: Flow = settingsFlow + override val workState: Flow = emptyFlow() + + override suspend fun initialize() { + } + + override suspend fun setAnalysisEnabled(enabled: Boolean) { + settingsFlow.value = settingsFlow.value.copy(analysisEnabled = enabled) + } + + override suspend fun setCategoryClassificationEnabled(enabled: Boolean) { + settingsFlow.value = settingsFlow.value.copy(categoryClassificationEnabled = enabled) + } + + override suspend fun requestAnalysis() { + } + + override suspend fun requestCategoryClassification() { + } + + override suspend fun cancelCategoryClassification() { + } + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtilTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtilTest.kt new file mode 100644 index 0000000000..4b8ce9a9d2 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/search/util/VectorUtilTest.kt @@ -0,0 +1,29 @@ +package com.dot.gallery.feature_node.presentation.search.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class VectorUtilTest { + + @Test + fun dot_returnsZeroForMismatchedDimensions() { + val result = floatArrayOf(1f, 2f) dot floatArrayOf(1f) + + assertEquals(0f, result) + } + + @Test + fun normalizeL2_returnsZeroVectorForInvalidInput() { + assertArrayEquals( + floatArrayOf(0f, 0f), + normalizeL2(inputArray = floatArrayOf(0f, 0f)), + 0f, + ) + assertArrayEquals( + floatArrayOf(0f), + normalizeL2(inputArray = floatArrayOf(Float.NaN)), + 0f, + ) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewManifestTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewManifestTest.kt new file mode 100644 index 0000000000..4969f341d1 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewManifestTest.kt @@ -0,0 +1,83 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import com.dot.gallery.feature_node.presentation.standalone.StandaloneActivity +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +internal class SecureReviewManifestTest { + + @Test + fun secureReviewIntentWithMimeType_resolvesOnlyToSecureReviewActivity() { + val activityNames = resolveActivityNames(action = MediaStore.ACTION_REVIEW_SECURE) + + assertEquals(setOf(SecureReviewActivity::class.java.name), activityNames) + } + + @Test + fun secureReviewIntentWithoutMimeType_resolvesOnlyToSecureReviewActivity() { + val activityNames = resolveActivityNames( + action = MediaStore.ACTION_REVIEW_SECURE, + mimeType = null, + ) + + assertEquals(setOf(SecureReviewActivity::class.java.name), activityNames) + } + + @Test + fun ordinaryReviewIntent_resolvesOnlyToStandaloneActivity() { + val activityNames = resolveActivityNames(action = MediaStore.ACTION_REVIEW) + + assertEquals(setOf(StandaloneActivity::class.java.name), activityNames) + } + + @Test + fun declaredJpegXlViewIntent_resolvesToStandaloneActivity() { + val activityNames = resolveActivityNames( + action = Intent.ACTION_VIEW, + mimeType = "image/jxl", + uri = Uri.parse("content://provider/image/1"), + ) + + assertEquals(setOf(StandaloneActivity::class.java.name), activityNames) + } + + @Test + fun genericMimeJpegXlFilename_doesNotResolveToStandaloneActivity() { + val activityNames = resolveActivityNames( + action = Intent.ACTION_VIEW, + mimeType = "application/octet-stream", + uri = Uri.parse("content://provider/image.jxl"), + ) + + assertEquals(emptySet(), activityNames) + } + + @Suppress("DEPRECATION") + private fun resolveActivityNames( + action: String, + mimeType: String? = "image/jpeg", + uri: Uri = Uri.parse("content://camera/image/1"), + ): Set { + val application = RuntimeEnvironment.getApplication() + val intent = Intent(action).apply { + when (mimeType) { + null -> data = uri + else -> setDataAndType(uri, mimeType) + } + setPackage(application.packageName) + } + + return application.packageManager + .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .map { resolveInfo -> resolveInfo.activityInfo.name } + .toSet() + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModelTest.kt b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModelTest.kt new file mode 100644 index 0000000000..b06e1408d4 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/feature_node/presentation/securereview/SecureReviewViewModelTest.kt @@ -0,0 +1,139 @@ +package com.dot.gallery.feature_node.presentation.securereview + +import android.net.Uri +import com.dot.gallery.feature_node.data.model.Media +import com.dot.gallery.feature_node.data.model.securereview.AuthorizedSecureReviewRequest +import com.dot.gallery.feature_node.data.repository.SecureReviewMediaRepository +import com.dot.gallery.testutil.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class SecureReviewViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun launchRequest_loadsMediaIntoUiState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val media = persistentListOf(mockk()) + val repository = mockRepository(media = media) + val viewModel = SecureReviewViewModel(repository = repository) + + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + val state = viewModel.uiState.value as SecureReviewUiState.Ready + assertSame(media, state.media) + } + } + + @Test + fun duplicateLaunchRequest_doesNotReloadMedia() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockRepository( + media = persistentListOf(mockk()), + ) + val viewModel = SecureReviewViewModel(repository = repository) + + viewModel.onLaunchRequest(request = request) + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + coVerify(exactly = 1) { + repository.loadMedia(request = request) + } + } + } + + @Test + fun loadFailure_emitsFinishEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = SecureReviewViewModel( + repository = mockRepository(media = null), + ) + + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + assertEquals(SecureReviewEffect.Finish, viewModel.effects.first()) + } + } + + @Test + fun incompleteLoad_emitsFinishEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val twoUriRequest = AuthorizedSecureReviewRequest( + uris = persistentListOf( + Uri.parse("content://example/image/1"), + Uri.parse("content://example/image/2"), + ), + ) + val viewModel = SecureReviewViewModel( + repository = mockRepository( + media = persistentListOf(mockk()), + ), + ) + + viewModel.onLaunchRequest(request = twoUriRequest) + advanceUntilIdle() + + assertEquals(SecureReviewEffect.Finish, viewModel.effects.first()) + } + } + + @Test + fun repositoryException_emitsFinishEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = mockk() + coEvery { repository.loadMedia(request = request) } throws IllegalStateException() + val viewModel = SecureReviewViewModel(repository = repository) + + viewModel.onLaunchRequest(request = request) + advanceUntilIdle() + + assertEquals(SecureReviewEffect.Finish, viewModel.effects.first()) + } + } + + @Test + fun closeClick_emitsFinishEffect() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = SecureReviewViewModel(repository = mockk(relaxed = true)) + + viewModel.onCloseClick() + advanceUntilIdle() + + assertEquals(SecureReviewEffect.Finish, viewModel.effects.first()) + } + } + + private fun mockRepository( + media: ImmutableList?, + ): SecureReviewMediaRepository { + return mockk { + coEvery { loadMedia(request = any()) } returns media + } + } + + companion object { + private val request = AuthorizedSecureReviewRequest( + uris = persistentListOf(Uri.parse("content://example/image/1")), + ) + } +} diff --git a/app/src/test/kotlin/com/dot/gallery/testutil/MainDispatcherRule.kt b/app/src/test/kotlin/com/dot/gallery/testutil/MainDispatcherRule.kt new file mode 100644 index 0000000000..47f90aa353 --- /dev/null +++ b/app/src/test/kotlin/com/dot/gallery/testutil/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package com.dot.gallery.testutil + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher = testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..8a093a9991 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=36 diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index 61588ffa94..3c39e7c993 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -5,32 +5,24 @@ plugins { id("androidx.baselineprofile") } +val baseApplicationId = providers + .gradleProperty("baseApplicationId") + .get() + android { + buildFeatures { + buildConfig = true + } + buildTypes { create("staging") { + matchingFallbacks += "release" } create("benchmarkStaging") { + matchingFallbacks += "release" } create("nonMinifiedStaging") { - } - } - flavorDimensions += listOf("abi") - productFlavors { - create("arm64-v8a") { - dimension = "abi" - ndk.abiFilters.add("arm64-v8a") - } - create("armeabi-v7a") { - dimension = "abi" - ndk.abiFilters.add("armeabi-v7a") - } - create("x86_64") { - dimension = "abi" - ndk.abiFilters.add("x86_64") - } - create("x86") { - dimension = "abi" - ndk.abiFilters.add("x86") + matchingFallbacks += "release" } } namespace = "com.dot.baselineprofile" @@ -46,6 +38,7 @@ android { targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "TARGET_APPLICATION_ID", "\"$baseApplicationId\"") } targetProjectPath = ":app" @@ -71,7 +64,7 @@ kotlin { baselineProfile { //managedDevices += "pixel6Api31" useConnectedDevices = true - + } dependencies { diff --git a/baselineprofile/src/main/java/com/dot/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/java/com/dot/baselineprofile/BaselineProfileGenerator.kt index 1588c44583..25251139e5 100644 --- a/baselineprofile/src/main/java/com/dot/baselineprofile/BaselineProfileGenerator.kt +++ b/baselineprofile/src/main/java/com/dot/baselineprofile/BaselineProfileGenerator.kt @@ -36,7 +36,7 @@ class BaselineProfileGenerator { @Test fun generate() { rule.collect( - packageName = "com.dot.gallery", + packageName = BuildConfig.TARGET_APPLICATION_ID, includeInStartupProfile = true ) { // This block defines the app's critical user journey. Here we are interested in diff --git a/baselineprofile/src/main/java/com/dot/baselineprofile/StartupBenchmarks.kt b/baselineprofile/src/main/java/com/dot/baselineprofile/StartupBenchmarks.kt index 653175e897..9f906e7a00 100644 --- a/baselineprofile/src/main/java/com/dot/baselineprofile/StartupBenchmarks.kt +++ b/baselineprofile/src/main/java/com/dot/baselineprofile/StartupBenchmarks.kt @@ -47,7 +47,7 @@ class StartupBenchmarks { private fun benchmark(compilationMode: CompilationMode) { rule.measureRepeated( - packageName = "com.dot.gallery", + packageName = BuildConfig.TARGET_APPLICATION_ID, metrics = listOf(StartupTimingMetric()), compilationMode = compilationMode, startupMode = StartupMode.COLD, diff --git a/fastlane/metadata/android/en-US/changelogs/10216.txt b/fastlane/metadata/android/en-US/changelogs/10216.txt deleted file mode 100644 index f2bc708aab..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/10216.txt +++ /dev/null @@ -1,13 +0,0 @@ -What's Changed - -- Added Settings Screen -- Added Translations for: English, Chinese-Simplified, Galician, Romanian, Spanish and Turkish -- Bug Fixes -- Improved App Bar -- Added support for launching Gallery from Camera app -- Added Album pinning feature -- Animate Photos Screen -- Improve media grouping in Photos Screen - - -Full Changelog: "https://github.com/IacobIonut01/Gallery/releases/tag/v1.0.2 diff --git a/fastlane/metadata/android/en-US/changelogs/20111.txt b/fastlane/metadata/android/en-US/changelogs/20111.txt deleted file mode 100644 index 78e3823d8a..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/20111.txt +++ /dev/null @@ -1,12 +0,0 @@ -New Features - Added option to create new albums - Fixed rotate button - Added option to disable vibration feedback - Fixed video screen timeout - Added blacklist feature - Fixed today listing on timeline - Added move feature - Fixed moving files not updating the media - Added copy feature - -Full Changelog: https://github.com/IacobIonut01/Gallery/releases/tag/2.0.1-20111 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/21000.txt b/fastlane/metadata/android/en-US/changelogs/21000.txt deleted file mode 100644 index c27a61a59c..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/21000.txt +++ /dev/null @@ -1,27 +0,0 @@ -New Features - Gallery: Added the ability to use Albums as the start screen. - Settings: Added a new UI for Donations. - Gallery: Introduced an initial version of the Editor. - Editor: Improved the crop layout and added a Filters and More section. - Gallery: Added support for JPEG-XL images. - Gallery: Added the ability to set the launch screen in Settings. - -Improvements - AlbumSizeScreen: Slightly improved the UI. - Gallery: Preloaded timeline and albums viewmodels outside navigation for better performance. - MediaView: Dismissed trash sheet on action complete for better user experience. - Misc: Updated 'favorite' string for better clarity. - Gallery: Album thumbnails are now loaded from URI for better performance. - Media: Improved the Copy Sheet. - Gallery: Fixed Sticky Header. - SelectionSheet: Fixed layout on big screens for better user experience. - Gallery: Improved overall speed and reduced media parsing calls for better performance. - MediaView: Made file renaming more obvious for better user experience. - -Bug Fixes - Gallery: Fixed MediaStore Observer. - Gallery: Fixed miscellaneous issues. - Gallery: Moved Media Manager request to Setup to fix potential issues. - Gallery: Fixed various bugs and migrated to Coil for better performance and stability. - -Full Changelog: https://github.com/IacobIonut01/Gallery/releases/tag/2.1.0-21000 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/21009.txt b/fastlane/metadata/android/en-US/changelogs/21009.txt deleted file mode 100644 index 18cd492353..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/21009.txt +++ /dev/null @@ -1,13 +0,0 @@ -New Features - Gallery: Added video player audio toggle button - Gallery: Automatically disable blur on battery saver - -Improvements - Gallery: Reduced lag for some devices - -Bug Fixes - Gallery: Fixed classic navigation bar colors - Gallery: Fixed some edge cases when navigation bar is showing incorrectly - Gallery: Fix missing 'Open as' for media - -Full Changelog: https://github.com/IacobIonut01/Gallery/releases/tag/2.1.0-21009 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/30033.txt b/fastlane/metadata/android/en-US/changelogs/30033.txt deleted file mode 100644 index 6d6cb8a4e6..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/30033.txt +++ /dev/null @@ -1,26 +0,0 @@ -Added - Vault - A secure way to hide and encrypt your photos - Ignored Albums - A safe and easy way to ignore certain albums or automatically ignore a bunch of them with Regex - Option to Move the album from trash from Album screen / Long press the album - Option to not hide the searchbar and/or the navigationbar while scrolling - New gesture: Drag down to close media viewer - New UI for Setup Wizard -Improved - UI Performance while viewing media grid - Media Content parsing and updating - MediaView screen - New UI and animations - Scroll Thumb - Slightly updated UI Design on Library tab - Empty and loading animations for media and albums - Detect initial grid size for bigger screens - Album sorting toggle - Re-arranged Settings screen -Fixed - Lags and hangs while fast scrolling the media grid - Major lags while scrolling and updating the Media Store (e.g., when new images are being added to your gallery, and you want to scroll the grid) - Small fixes to the video player -Changed - Moved to a new image loading library (sketch) - Moved to a new image subsampling library (com.github.panpf.zoomimage) -Removed - Temporarily removed the Image Editor - a new one will be coming soon \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40070.txt b/fastlane/metadata/android/en-US/changelogs/40070.txt deleted file mode 100644 index e4bd587e2b..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/40070.txt +++ /dev/null @@ -1,41 +0,0 @@ -Improvements - -- Faster media grid loading & smoother scrolling (downsampling, buffer reuse, single‑flight decrypt, adaptive thresholds) -- Reduced memory & GC pressure (temp spill for large decrypts, ByteArrayPool, sidecar metadata cache, decrypt LRU) -- Redesigned UI with large‑screen accessibility enhancements -- Reliable audio focus toggle (player rebuild logic) -- Deferred & safe original media deletion after successful vault operations -- Unified all‑workers progress screen -- Streamlined copy progress (ViewModel + Flow) -- Enhanced media info panel (richer technical metadata) -- More resilient vault operations (missing URIs skipped, portable vault migration fallback) -- Album screen layout toggle (GridView ⇆ ColumnView) -- Revamped VideoPlayer with latest Media3-Compose components - -New Features - -- Encrypted media streaming architecture (EncryptedMediaSource, streaming video groundwork) -- Image rotation action -- Image lock mode (prevent scrolling/swiping) -- Sidecar metadata caching + scheduled cleanup -- Adaptive decrypt threshold -- Workers progress screen -- Byte‑level aggregated copy progress -- Audio focus user preference -- Metrics instrumentation (internal) [removed logging claims] -- Unified deletion handoff via worker output -- Updated & merged translations - -Bug Fixes - -- Fixed Vault not encrypting or decrypting correctly -- Premature deletion during encrypt/hide eliminated -- ENOENT playback errors removed (no empty Uri before decrypt ready) -- Play/pause icon desync after paused seek fixed -- Large redundant decrypted byte[] allocations avoided -- FileNotFoundException from missing MediaStore items prevented -- Audio focus OFF no longer pauses external audio apps -- Playback position preserved across orientation changes -- UI stall on large encrypted video start reduced (async decrypt) -- Duplicate decrypts on rapid scroll prevented (single‑flight) -- Copy worker progress no longer jumps instantly or appears frozen \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/41002.txt b/fastlane/metadata/android/en-US/changelogs/41002.txt deleted file mode 100644 index d2bad31070..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/41002.txt +++ /dev/null @@ -1,39 +0,0 @@ -ReFra v4.1.0 — The Refraction Update - -App Rebrand -- Gallery is now ReFra! Reflecting a fresh identity inspired by the refraction of light -- New geometric launcher icon with themed monochrome support -- Google Sans font family across the entire app - -New Features - -- 360° Panorama & Photosphere Viewer: OpenGL-powered viewer with touch/gyroscope navigation and compass overlay (#628) -- Motion Photo Playback: Detects and plays embedded video in Google Motion Photos, Samsung Live Photos, and Micro Videos with filmstrip scrubbing and haptic snap-to-favourite (#395) -- Custom Color Palette: Choose from wallpaper-derived colors, 12 preset palettes, or system dynamic color with live phone preview -- Location Map Viewer: Interactive Mapbox map with clustered markers, bottom sheet media grid, and adaptive layouts -- AI-Powered Categories: CLIP-based classification replacing the legacy ONNX model — create custom categories with search terms and accuracy thresholds -- Smart Search Discovery: Carousels for categories, locations, MIME types, camera models, and special modes (night, panorama, long exposure) -- Video Blur Effects: Haze blur behind video player UI via PixelCopy-based SurfaceView capture -- Redesigned Media Picker: Album grid with shared transitions, pill tab switcher (Recents/Albums) - -Improvements - -- Compact media viewer action buttons (48dp circles with tooltips) -- Reduced bottom bar height (100dp → 64dp) for more viewing space -- Improved Add/Edit category screens with best-match accuracy slider -- Locations map: consistent back button, better blur styling -- Media viewer sheet cards constrained to 600dp max width on wide screens -- Blur background stays fixed while swiping between media -- Enhanced settings with swipe-to-delete and long-press actions -- Updated build toolchain: Gradle 9.3.1, AGP 9.1.0, Kotlin 2.3.20 -- Dependency updates: Compose 1.10.6, Media3 1.10.0, Hilt 2.59.2, and more - -Bug Fixes - -- Fixed light mode video player buttons being invisible (#675) -- Fixed search crash on exit or query change (#801) -- Fixed color palette phone preview breaking in landscape -- Fixed locations map state types (mutableDoubleStateOf) -- Improved media distribution with permission-aware album loading - -Updated translations across 40+ languages diff --git a/fastlane/metadata/android/en-US/changelogs/41106.txt b/fastlane/metadata/android/en-US/changelogs/41106.txt deleted file mode 100644 index f26d5a38f6..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/41106.txt +++ /dev/null @@ -1,22 +0,0 @@ -ReFra v4.1.1 - -New Features - -- Enhanced Markup Drawing (#760): Pinch-to-zoom, pan gestures, full-screen mode, and floating tools overlay in the photo editor markup mode -- Copy Image to Clipboard (#783): Quickly copy any image to the system clipboard -- Edit Backups Viewer: Browse and compare original vs edited images with zoomable viewer, storage stats, and batch cleanup -- Dynamic App Name (#811): Switch between app name aliases (ReFra / Gallery) from settings -- Favorite Icon Customization (#802): Choose favorite icon position on thumbnails and toggle visibility in the media viewer, with animated settings preview - -Bug Fixes - -- Fixed vault encryption crashing on large files (#796) — replaced in-memory encryption with chunked streaming to prevent OOM on files >20MB -- Fixed wrong image shown when opening from external file manager (#797) -- Fixed JPEG-XL files wrapped in ISO-BMFF containers not being recognized (#803) -- Fixed force quit on device rotation (#795) -- Fixed inverted gyroscope horizontal yaw direction in photosphere viewer - -Under the Hood - -- ML models moved to install-time asset pack to reduce base APK size -- Room database migration to v16 for edit history tracking (EditedMedia entity) diff --git a/fastlane/metadata/android/en-US/changelogs/41203.txt b/fastlane/metadata/android/en-US/changelogs/41203.txt deleted file mode 100644 index b28c1818ed..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/41203.txt +++ /dev/null @@ -1,11 +0,0 @@ -ReFra v4.1.2 - -New Features - -- Android 10 (API 29) Support (#671): Extended compatibility down to Android 10 with graceful fallbacks for Trash, Favorites, and biometric prompts -- Home Screen Widgets: Single photo and grid media widgets with configurable album source -- Album Locking: Lock albums behind biometric authentication for added privacy -- Media Grouping: Automatically group related media (RAW+JPG, bursts, edits) with a viewer strip and search categories -- Neutral Theme: New neutral theme option with gray backgrounds and colorful accents -- Default Editor Setting: Choose your preferred default image editor -- GIF Animation Toggle: Option to disable GIF animation in the grid view for reduced distraction and improved performance diff --git a/fastlane/metadata/android/en-US/changelogs/41301.txt b/fastlane/metadata/android/en-US/changelogs/41301.txt deleted file mode 100644 index 5a9305ad6d..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/41301.txt +++ /dev/null @@ -1,13 +0,0 @@ -ReFra v4.1.3 - -New Features - -- Revamped Edit Screen: Completely remade photo editor with enhanced tools and text overlay support -- Full Metadata Viewer: View all metadata for images and videos directly in the media detail view -- MapLibre Migration: Replaced Mapbox with MapLibre using OpenFreeMap tiles, removing the need for a Mapbox token -- Sandboxed Metadata Parsing: Media metadata parsing now runs in an isolated process for improved stability - -Bug Fixes - -- Fixed on-demand metadata fetching for media detail view (#817) -- Fixed ClassCastException in PickerActivity by extending FragmentActivity (#823) diff --git a/fastlane/metadata/android/en-US/changelogs/42001.txt b/fastlane/metadata/android/en-US/changelogs/42001.txt deleted file mode 100644 index 57ce350910..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/42001.txt +++ /dev/null @@ -1,25 +0,0 @@ -ReFra v4.2.0 - -New Features - -- Custom Collections: Create and manage custom albums with add-to-collection from selection sheet -- Album Groups: Organize albums into groups with CRUD, group view, and copy/move picker integration -- Mosaic Timeline Layout: New mosaic layout with seedable tile distribution and sticky headers -- Image-to-Image Search: Find visually similar images using AI-powered search -- Frame-Accurate Video Seeking: Sensitivity-aware scrubbing for precise video navigation -- Merge Albums: Option to merge albums with the same name or merge subfolder albums into parent (#693) -- Optional ML Models: AI models can now be downloaded on-demand instead of being bundled -- Granular Grouping Settings: Separate controls for RAW+JPG, edited copies, and burst sequences -- Configurable Actions: Customizable selection sheet and media viewer action buttons -- Help & Tips: In-app release notes and navigation guide -- Vault Custom Authentication: Set a separate PIN, pattern, or password per vault -- Multi-Vault Selection: Choose which vault to open with item counts -- Vault Encrypt Behavior: Configurable default action (ask, delete originals, keep originals) - -Bug Fixes - -- Fixed video codec exhaustion and black screen on fast scroll -- Fixed 'show favorite button' setting not respected in timeline and selection sheet (#831) -- Fixed categories spacing and shared element animation issues -- Fixed baseline profile install issue -- Fixed various Vault UI and behavior issues diff --git a/fastlane/metadata/android/en-US/changelogs/42101.txt b/fastlane/metadata/android/en-US/changelogs/42101.txt deleted file mode 100644 index 1b347b4212..0000000000 --- a/fastlane/metadata/android/en-US/changelogs/42101.txt +++ /dev/null @@ -1,13 +0,0 @@ -What's new in 4.2.1: - -• FCast Video Casting — Cast videos and images to FCast-compatible devices on your local network with mDNS device discovery, remote playback controls, and encrypted vault media support -• Auto-Contrast (Beta) — Real-time luminance detection automatically enhances display contrast in the media viewer -• Vault Overhaul — Global gate authentication (None / Device Security / Custom Password), custom per-vault passwords, redesigned vault creation flow, and improved vault navigation -• Right-Align Selection Actions — Option to push top action buttons to the right for easier one-handed use -• NoMaps WithML Build Variant — New build variant with AI models but without map dependencies - -Bug fixes & improvements: -• Fixed phantom selections when media is externally deleted -• Corrected permission list on setup screen for maps/nomaps variants -• Capitalized ML in product flavor names -• Improved image loading (sketch lib 4.5.0-alpha03) diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt deleted file mode 100644 index af838585dc..0000000000 --- a/fastlane/metadata/android/en-US/full_description.txt +++ /dev/null @@ -1,68 +0,0 @@ -

ReFra is a modern, open-source media gallery for Android built with Jetpack Compose. It refracts how you browse, organize, and experience your photos and videos — with AI-powered categories, panorama viewing, motion photo playback, video casting, and a fully customizable Material 3 interface.

- -

 

- -

Features

- -
    -
  • Media Timeline with date-grouped photos and videos -
      -
    • Grid and mosaic layout modes
    • -
    • Smooth scrolling with optimized thumbnail loading
    • -
    • Smart grouping for RAW+JPG pairs, burst shots, and edited copies
    • -
    -
  • -
  • 360° Panorama & Photosphere Viewer -
      -
    • OpenGL-powered spherical and cylindrical projections
    • -
    • Touch and gyroscope navigation with compass overlay
    • -
    -
  • -
  • Motion Photo Playback -
      -
    • Detects Google Motion Photos, Samsung Live Photos, and Micro Videos
    • -
    • Interactive filmstrip scrubber with haptic feedback
    • -
    -
  • -
  • FCast Video Casting -
      -
    • Cast videos and images to devices on your local network
    • -
    • mDNS device discovery with remote playback controls
    • -
    -
  • -
  • AI-Powered Categories & Search -
      -
    • On-device CLIP-based image classification with customizable categories
    • -
    • Natural language search and image-to-image similarity search
    • -
    • Optional AI model downloads — not bundled, installed on demand
    • -
    -
  • -
  • Location Map Viewer -
      -
    • Interactive MapLibre map with clustered photo markers
    • -
    • Browse geotagged media by location
    • -
    -
  • -
  • Smart Search with Discovery -
      -
    • Carousels for categories, locations, media types, camera models
    • -
    • Special mode filters: night shots, long exposures, panoramas
    • -
    -
  • -
  • Custom Color Palettes -
      -
    • Pick from wallpaper-derived or preset color schemes
    • -
    • Live preview with full Material 3 theme generation
    • -
    -
  • -
  • Albums with Groups, Collections, and Merge by Name
  • -
  • Multi-Select with configurable action sheet and right-align option
  • -
  • Sortable and pinnable albums
  • -
  • Encrypted Vault with gate authentication, custom passwords, and biometric unlock
  • -
  • Built-in Photo Editor with markup, crop, filters, and edit backups
  • -
  • Custom Video Player powered by Media3 ExoPlayer with frame-accurate seeking
  • -
  • Auto-contrast viewer enhancement (Beta)
  • -
  • Full EXIF and metadata viewer
  • -
  • Google Sans typography and modern Material 3 design
  • -
  • Open Source (Apache-2.0) with active development
  • -
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png deleted file mode 100644 index 1ea0c92206..0000000000 Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png deleted file mode 100644 index 439e2d1753..0000000000 Binary files a/fastlane/metadata/android/en-US/images/icon.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png deleted file mode 100644 index 545c0a3c5d..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png deleted file mode 100644 index df99c4a485..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png deleted file mode 100644 index d7f25b7480..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png deleted file mode 100644 index dd1baeade3..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png deleted file mode 100644 index a6e7355fb1..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png deleted file mode 100644 index 18572e7a95..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png deleted file mode 100644 index d027d129e8..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png deleted file mode 100644 index b18164697f..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png deleted file mode 100644 index 49250f7bc9..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png deleted file mode 100644 index 15ac4ee058..0000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt deleted file mode 100644 index 45e551ae29..0000000000 --- a/fastlane/metadata/android/en-US/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -ReFra — A modern, open-source media gallery \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index f6f9247670..bdea7a5058 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,5 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true org.gradle.unsafe.configuration-cache=true -android.suppressUnsupportedCompileSdk=35 \ No newline at end of file +android.suppressUnsupportedCompileSdk=35 +baseApplicationId=app.grapheneos.gallery diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0899230752..ea52c35e81 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ adaptive = "1.2.0" agp = "9.1.1" aire = "0.18.1" +androidSvg = "1.4" avifCoderCoil = "2.2.0" benchmarkMacroJunit4 = "1.5.0-alpha05" biometric = "1.2.0-alpha05" @@ -16,7 +17,6 @@ hazeMaterials = "1.7.2" jxlCoder = "2.6.0" kotlin = "2.3.21" kotlinCollectionsImmutable = "0.4.0" -nanohttpd = "2.3.1" kotlinCoroutinesVersion = "1.10.2" ksp = "2.3.4" core-ktx = "1.18.0" @@ -24,7 +24,6 @@ junit = "4.13.2" androidx-test-ext-junit = "1.3.0" espresso-core = "3.7.0" lazycolumnscrollbar = "2.2.0" -maplibreNative = "13.1.0" lifecycle-runtime = "2.10.0" activity-compose = "1.13.0" hilt = "2.59.2" @@ -33,13 +32,14 @@ material = "1.13.0" material3 = "1.5.0-alpha18" media3Exoplayer = "1.10.0" metadataExtractor = "2.20.0" +mockk = "1.14.11" navigation-runtime-ktx = "2.9.8" onnxruntimeAndroid = "1.25.1" profileinstaller = "1.4.1" room = "2.8.4" +robolectric = "4.16" accompanist = "0.37.3" datastore = "1.2.1" -securityCrypto = "1.1.0" sketch = "4.5.0-alpha03" startupRuntime = "1.2.0" uiautomator = "2.3.0" @@ -55,6 +55,7 @@ glide-ksp = "5.0.5" # AndroidX android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } aire = { module = "com.github.awxkee:aire", version.ref = "aire" } +androidsvg = { module = "com.caverock:androidsvg-aar", version.ref = "androidSvg" } androidx-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" } androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" } @@ -76,10 +77,10 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } # Coil -androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startupRuntime" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "workRuntimeKtx" } avif-coder-coil = { module = "io.github.awxkee:avif-coder-coil", version.ref = "avifCoderCoil" } # Compose @@ -115,9 +116,7 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "hazeMaterials" } jxl-coder-coil = { module = "io.github.awxkee:jxl-coder-coil", version.ref = "jxlCoder" } lazycolumnscrollbar = { module = "com.github.nanihadesuka:LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } -maplibre-native = { module = "org.maplibre.gl:android-sdk", version.ref = "maplibreNative" } metadata-extractor = { module = "com.drewnoakes:metadata-extractor", version.ref = "metadataExtractor" } -nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } onnxruntime-android = { module = "com.microsoft.onnxruntime:onnxruntime-android", version.ref = "onnxruntimeAndroid" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } @@ -145,6 +144,7 @@ sketch-view = { module = "io.github.panpf.sketch4:sketch-view", version.ref = "s kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinCollectionsImmutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinCoroutinesVersion" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutinesVersion" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutinesVersion" } # View-based AndroidX material = { module = "com.google.android.material:material", version.ref = "material" } @@ -163,6 +163,8 @@ zoomimage-compose-glide = { module = "io.github.panpf.zoomimage:zoomimage-compos junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } @@ -178,4 +180,3 @@ baselineProfilePlugin = { id = "androidx.baselineprofile", version.ref = "benchm kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [bundles] - diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 0000000000..818c9ad063 --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,25046 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a65bb4ef7d..4bdb2a9ebd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,7 @@ -#Sun Mar 22 17:00:38 EET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/libs/cropper/src/main/java/com/smarttoolfactory/cropper/ImageCropper.kt b/libs/cropper/src/main/java/com/smarttoolfactory/cropper/ImageCropper.kt index 77b3c239a8..8670ab3dae 100644 --- a/libs/cropper/src/main/java/com/smarttoolfactory/cropper/ImageCropper.kt +++ b/libs/cropper/src/main/java/com/smarttoolfactory/cropper/ImageCropper.kt @@ -1,6 +1,7 @@ package com.smarttoolfactory.cropper import android.content.res.Configuration +import android.graphics.RectF import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing @@ -31,6 +32,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import com.smarttoolfactory.cropper.crop.CropAgent @@ -38,6 +40,7 @@ import com.smarttoolfactory.cropper.draw.DrawingOverlay import com.smarttoolfactory.cropper.draw.ImageDrawCanvas import com.smarttoolfactory.cropper.image.ImageWithConstraints import com.smarttoolfactory.cropper.image.getScaledImageBitmap +import com.smarttoolfactory.cropper.model.CropData import com.smarttoolfactory.cropper.model.CropOutline import com.smarttoolfactory.cropper.settings.CropDefaults import com.smarttoolfactory.cropper.settings.CropProperties @@ -63,9 +66,11 @@ fun ImageCropper( filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, crop: Boolean = false, cropEnabled: Boolean = true, + initialCropRect: RectF? = null, onCropStart: () -> Unit, onCropSuccess: (ImageBitmap) -> Unit, - onCropRect: ((android.graphics.RectF) -> Unit)? = null, + onCropRect: ((RectF) -> Unit)? = null, + onCropRectChanged: ((RectF) -> Unit)? = null, backgroundModifier: Modifier = Modifier ) { @@ -146,6 +151,18 @@ fun ImageCropper( } } + LaunchedEffect(cropState) { + val restoredOverlayRect = initialCropRect?.toOverlayRect( + sourceRect = rect, + imageWidth = imageBitmap.width, + imageHeight = imageBitmap.height, + drawAreaRect = cropState.drawAreaRect, + ) + if (restoredOverlayRect != null) { + cropState.snapOverlayRectTo(rect = restoredOverlayRect) + } + } + val pressedStateColor = remember(cropStyle.backgroundColor) { cropStyle.backgroundColor .copy(cropStyle.backgroundColor.alpha * .7f) @@ -167,25 +184,36 @@ fun ImageCropper( onCropSuccess, onCropRect = onCropRect?.let { callback -> { cropRect -> - // cropRect is in scaledImageBitmap pixel coords. - // scaledImageBitmap is a 1:1 pixel sub-region of imageBitmap at (rect.left, rect.top). - // Normalize to 0-1 relative to the full input imageBitmap. - val imgW = imageBitmap.width.toFloat() - val imgH = imageBitmap.height.toFloat() - val left = (rect.left + cropRect.left) / imgW - val top = (rect.top + cropRect.top) / imgH - val right = (rect.left + cropRect.right) / imgW - val bottom = (rect.top + cropRect.bottom) / imgH - callback(android.graphics.RectF(left, top, right, bottom)) + callback( + cropRect.toNormalizedRect( + sourceRect = rect, + imageWidth = imageBitmap.width, + imageHeight = imageBitmap.height, + ) + ) } } ) + val notifyCropRectChanged = onCropRectChanged?.let { callback -> + { cropData: CropData -> + callback( + cropData.cropRect.toNormalizedRect( + sourceRect = rect, + imageWidth = imageBitmap.width, + imageHeight = imageBitmap.height, + ) + ) + } + } + val imageModifier = Modifier .size(containerWidth, containerHeight) .crop( keys = resetKeys, - cropState = cropState + cropState = cropState, + onUp = notifyCropRectChanged, + onGestureEnd = notifyCropRectChanged, ) LaunchedEffect(key1 = cropProperties) { @@ -220,6 +248,53 @@ fun ImageCropper( } } +private fun Rect.toNormalizedRect( + sourceRect: IntRect, + imageWidth: Int, + imageHeight: Int, +): RectF { + val sourceWidth = imageWidth.toFloat() + val sourceHeight = imageHeight.toFloat() + return RectF( + ((sourceRect.left + left) / sourceWidth).coerceIn(0f, 1f), + ((sourceRect.top + top) / sourceHeight).coerceIn(0f, 1f), + ((sourceRect.left + right) / sourceWidth).coerceIn(0f, 1f), + ((sourceRect.top + bottom) / sourceHeight).coerceIn(0f, 1f), + ) +} + +private fun RectF.toOverlayRect( + sourceRect: IntRect, + imageWidth: Int, + imageHeight: Int, + drawAreaRect: Rect, +): Rect? { + if (drawAreaRect.isEmpty) { + return null + } + + val scaledLeft = left * imageWidth - sourceRect.left + val scaledTop = top * imageHeight - sourceRect.top + val scaledRight = right * imageWidth - sourceRect.left + val scaledBottom = bottom * imageHeight - sourceRect.top + if (scaledRight <= scaledLeft || scaledBottom <= scaledTop) { + return null + } + + val sourceWidth = sourceRect.width.toFloat() + val sourceHeight = sourceRect.height.toFloat() + if (sourceWidth <= 0f || sourceHeight <= 0f) { + return null + } + + return Rect( + left = drawAreaRect.left + drawAreaRect.width * (scaledLeft / sourceWidth), + top = drawAreaRect.top + drawAreaRect.height * (scaledTop / sourceHeight), + right = drawAreaRect.left + drawAreaRect.width * (scaledRight / sourceWidth), + bottom = drawAreaRect.top + drawAreaRect.height * (scaledBottom / sourceHeight), + ).intersect(drawAreaRect) +} + @Composable private fun ImageCropper( modifier: Modifier, @@ -401,4 +476,4 @@ private fun getResetKeys( cropType, fixedAspectRatio, ) -} \ No newline at end of file +} diff --git a/libs/panoramaviewer/README.md b/libs/panoramaviewer/README.md index 0f0dab829e..726a97f8a9 100644 --- a/libs/panoramaviewer/README.md +++ b/libs/panoramaviewer/README.md @@ -45,7 +45,7 @@ PanoramaViewer (Compose entry-point) | `PanoramaViewer` | Composable entry-point | | `ProjectionType` | `SPHERE` or `CYLINDER` enum | | `CameraState` | Data class with yaw, pitch, fov, arcDegrees, projectionType | -| `PanoramaImageLoader` | Interface for custom image loading (encrypted, network, etc.) | +| `PanoramaImageLoader` | Interface for custom image loading (network, archives, etc.) | | `PanoramaLog` | Toggleable debug logger (`PanoramaLog.enabled = true`) | All other classes are `internal` and not part of the public API. @@ -53,8 +53,8 @@ All other classes are `internal` and not part of the public API. ## Custom Image Loader By default, `PanoramaViewer` loads images from a content URI using Android's -`BitmapRegionDecoder`. To load images from other sources — encrypted vault files, -network streams, custom archives — implement the `PanoramaImageLoader` interface +`BitmapRegionDecoder`. To load images from other sources — network streams, +custom archives, generated images — implement the `PanoramaImageLoader` interface and pass it via the `imageLoader` parameter. ### Interface @@ -85,33 +85,25 @@ lifecycle automatically: the requested rectangle at high resolution. 4. **`close()`** — Called when the viewer is disposed. Release all resources. -### Example: Encrypted Vault Loader +### Example: Byte Array Loader ```kotlin -class EncryptedPanoramaImageLoader( - private val keychainHolder: KeychainHolder, - private val encryptedFile: File +class ByteArrayPanoramaImageLoader( + private val data: ByteArray, ) : PanoramaImageLoader { private var decoder: BitmapRegionDecoder? = null - private var decryptedBytes: ByteArray? = null override var imageWidth: Int = 0; private set override var imageHeight: Int = 0; private set override fun initialize(): Boolean { - // Decrypt once, then use the raw bytes for all decoding - val media = with(keychainHolder) { - encryptedFile.decryptKotlin() - } - decryptedBytes = media.bytes - val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(media.bytes, 0, media.bytes.size, opts) + BitmapFactory.decodeByteArray(data, 0, data.size, opts) imageWidth = opts.outWidth imageHeight = opts.outHeight - decoder = BitmapRegionDecoder.newInstance(media.bytes, 0, media.bytes.size) + decoder = BitmapRegionDecoder.newInstance(data, 0, data.size) return decoder != null } @@ -133,7 +125,7 @@ class EncryptedPanoramaImageLoader( } override fun close() { - decoder?.recycle(); decoder = null; decryptedBytes = null + decoder?.recycle(); decoder = null } } ``` @@ -142,7 +134,7 @@ class EncryptedPanoramaImageLoader( ```kotlin val loader = remember(media.id) { - EncryptedPanoramaImageLoader(keychainHolder, encryptedFile) + ByteArrayPanoramaImageLoader(imageBytes) } PanoramaViewer( imageUri = Uri.EMPTY, // ignored when imageLoader is set diff --git a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaGLSurfaceView.kt b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaGLSurfaceView.kt index 7f934df85f..ef41e7d492 100644 --- a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaGLSurfaceView.kt +++ b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaGLSurfaceView.kt @@ -165,7 +165,7 @@ internal class PanoramaGLSurfaceView( * Sets a custom [PanoramaImageLoader] as the image source. * * Use this instead of [setImageSource] when you need custom loading logic, - * such as decrypting vault media or loading from a network source. + * such as loading from a network source or custom archive. * * The loader's [PanoramaImageLoader.initialize], [PanoramaImageLoader.loadBase], * and [PanoramaImageLoader.loadRegion] methods will be called on a background diff --git a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaImageLoader.kt b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaImageLoader.kt index 24ca0ead48..050a6ca006 100644 --- a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaImageLoader.kt +++ b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaImageLoader.kt @@ -12,8 +12,7 @@ import java.io.Closeable * Abstraction for loading panoramic image data into the [PanoramaViewer]. * * Implement this interface to provide custom image loading logic — for example, - * to load encrypted vault media, images from a network source, or images stored - * in a custom format. + * to load images from a network source or images stored in a custom format. * * ## Lifecycle * diff --git a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaViewer.kt b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaViewer.kt index d3334f129e..0d294af5e4 100644 --- a/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaViewer.kt +++ b/libs/panoramaviewer/src/main/kotlin/com/dot/gallery/libs/panoramaviewer/PanoramaViewer.kt @@ -58,10 +58,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner * ) * ``` * - * ## Custom loader usage (e.g. encrypted vault) + * ## Custom loader usage * * ```kotlin - * val loader = remember(media) { MyEncryptedLoader(keychainHolder, file) } + * val loader = remember(media) { MyCustomLoader(file) } * PanoramaViewer( * imageUri = Uri.EMPTY, // ignored when imageLoader is set * projectionType = ProjectionType.SPHERE, @@ -78,8 +78,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner * (recommended for photospheres). * @param imageLoader Optional custom [PanoramaImageLoader] for loading image data. * When provided, this is used instead of the default - * `ContentResolver`-based loader. Useful for encrypted vault - * media, network images, or custom formats. + * `ContentResolver`-based loader. Useful for network + * images or custom formats. * @param onTap Optional callback invoked on a single-tap gesture * (e.g. to toggle surrounding UI chrome). * @param onCameraChanged Optional callback invoked whenever the camera state changes diff --git a/play_release.patch b/play_release.patch deleted file mode 100644 index 413ccc95f6..0000000000 --- a/play_release.patch +++ /dev/null @@ -1,72 +0,0 @@ -From 92a17b8339522adffa019d78dac3664e0265e630 Mon Sep 17 00:00:00 2001 -From: IacobIonut01 -Date: Sun, 10 Mar 2024 19:11:18 +0200 -Subject: [PATCH] Add google play release flavor - ---- - app/build.gradle.kts | 10 +++++++++- - libs/cropper/build.gradle.kts | 4 ++++ - libs/gesture/build.gradle.kts | 4 ++++ - 3 files changed, 17 insertions(+), 1 deletion(-) - -diff --git a/app/build.gradle.kts b/app/build.gradle.kts -index edabd18..e30d6eb 100644 ---- a/app/build.gradle.kts -+++ b/app/build.gradle.kts -@@ -17,7 +17,7 @@ android { - compileSdk = 34 - - defaultConfig { -- applicationId = "com.dot.gallery" -+ applicationId = "com.dot.gallery.gplay" - minSdk = 30 - targetSdk = 34 - versionCode = 21009 -@@ -60,6 +60,14 @@ android { - buildConfigField("String", "MAPS_TOKEN", getApiKey()) - buildConfigField("String", "CONTENT_AUTHORITY", "\"com.dot.gallery.media_provider\"") - } -+ create("gplay") { -+ initWith(getByName("release")) -+ ndk.debugSymbolLevel = "FULL" -+ manifestPlaceholders += mapOf( -+ "appProvider" to "com.dot.gallery.media_provider.gplay" -+ ) -+ buildConfigField("String", "CONTENT_AUTHORITY", "\"com.dot.gallery.media_provider.gplay\"") -+ } - create("staging") { - initWith(getByName("release")) - isMinifyEnabled = false -diff --git a/libs/cropper/build.gradle.kts b/libs/cropper/build.gradle.kts -index c13dee6..789946a 100644 ---- a/libs/cropper/build.gradle.kts -+++ b/libs/cropper/build.gradle.kts -@@ -19,6 +19,10 @@ android { - isMinifyEnabled = false - isShrinkResources = false - } -+ create("gplay") { -+ initWith(getByName("release")) -+ ndk.debugSymbolLevel = "FULL" -+ } - } - - compileOptions { -diff --git a/libs/gesture/build.gradle.kts b/libs/gesture/build.gradle.kts -index 4390ba5..ae65f3e 100644 ---- a/libs/gesture/build.gradle.kts -+++ b/libs/gesture/build.gradle.kts -@@ -17,6 +17,10 @@ android { - isMinifyEnabled = false - isShrinkResources = false - } -+ create("gplay") { -+ initWith(getByName("release")) -+ ndk.debugSymbolLevel = "FULL" -+ } - } - - compileOptions { --- -2.42.0 - diff --git a/scripts/build-and-copy-to-gos.sh b/scripts/build-and-copy-to-gos.sh new file mode 100755 index 0000000000..8533a7f09c --- /dev/null +++ b/scripts/build-and-copy-to-gos.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Use to quickly build and replace release version in the GOS build tree +# Before running, do `ln -s "$HOME/.android/debug.keystore" app/release_key.jks` to use your debug key for +# release signing or create a keystore in `app/release_key.jks` + +set -e + +if [ -z "${GOS_ROOT:-}" ]; then + echo "GOS_ROOT must be set before running this script." >&2 + exit 1 +fi + +SIGNING_STORE_PASSWORD=android \ + SIGNING_KEY_ALIAS=androiddebugkey \ + SIGNING_KEY_PASSWORD=android \ + ./gradlew :app:assembleRelease + +cp app/build/outputs/apk/release/Gallery-arm64-v8a-release.apk "$GOS_ROOT/external/Gallery/prebuilt/Gallery-arm64-v8a-release.apk" +cp app/build/outputs/apk/release/Gallery-x86_64-release.apk "$GOS_ROOT/external/Gallery/prebuilt/Gallery-x86_64-release.apk" diff --git a/strip_allfiles_permission.sh b/strip_allfiles_permission.sh deleted file mode 100644 index 58658bae62..0000000000 --- a/strip_allfiles_permission.sh +++ /dev/null @@ -1,2 +0,0 @@ -sed -n '//!p' app/src/main/AndroidManifest.xml > app/src/main/AndroidManifest2.xml -mv app/src/main/AndroidManifest2.xml app/src/main/AndroidManifest.xml \ No newline at end of file diff --git a/strip_permission.sh b/strip_permission.sh deleted file mode 100644 index f89d2ff8dd..0000000000 --- a/strip_permission.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -sed \ - -e '//d' \ - -e '//d' \ - -e '//d' \ - -e '//d' \ - app/src/main/AndroidManifest.xml > app/src/main/AndroidManifest2.xml -mv app/src/main/AndroidManifest2.xml app/src/main/AndroidManifest.xml \ No newline at end of file