Compare commits
16 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5b6d7af0ac | ||
![]() |
aa2b4c14e0 | ||
![]() |
bf322c0aa9 | ||
![]() |
1b87fb896d | ||
![]() |
20f6d3f2a7 | ||
![]() |
ffc6201e28 | ||
![]() |
c4e06113b4 | ||
![]() |
271809e189 | ||
![]() |
fb8a97c5c6 | ||
![]() |
cf64527c4b | ||
![]() |
055d341a84 | ||
![]() |
dc34fc6bc2 | ||
![]() |
fa16430063 | ||
![]() |
58e7adecde | ||
![]() |
b8e8e3bfba | ||
![]() |
79c5f5777b |
1341 changed files with 27325 additions and 59548 deletions
2
.flutter
2
.flutter
|
@ -1 +1 @@
|
|||
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
||||
Subproject commit 9e1c857886f07d342cf106f2cd588bcd5e031bb2
|
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
@ -1,6 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
26
.github/workflows/check.yml
vendored
Normal file
26
.github/workflows/check.yml
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
name: Quality check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check code quality.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get packages for the Flutter project.
|
||||
run: scripts/pub_get_all.sh
|
||||
|
||||
- name: Update the flutter version file.
|
||||
run: scripts/update_flutter_version.sh
|
||||
|
||||
- name: Static analysis.
|
||||
run: ./flutterw analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: ./flutterw test
|
27
.github/workflows/dependency-review.yml
vendored
27
.github/workflows/dependency-review.yml
vendored
|
@ -1,27 +0,0 @@
|
|||
# Dependency Review Action
|
||||
#
|
||||
# This Action will scan dependency manifest files that change as part of a Pull Request,
|
||||
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
|
||||
# Once installed, if the workflow run is marked as required,
|
||||
# PRs introducing known-vulnerable packages will be blocked from merging.
|
||||
#
|
||||
# Source repository: https://github.com/actions/dependency-review-action
|
||||
name: 'Dependency Review'
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
91
.github/workflows/quality-check.yml
vendored
91
.github/workflows/quality-check.yml
vendored
|
@ -1,91 +0,0 @@
|
|||
name: Quality check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop", "main" ]
|
||||
pull_request:
|
||||
branches: [ "develop", "main" ]
|
||||
types: [ opened, synchronize, reopened ]
|
||||
schedule:
|
||||
- cron: '17 8 * * 3'
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze_flutter:
|
||||
name: Flutter analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get Flutter packages
|
||||
run: ./flutterw pub get
|
||||
|
||||
- name: Generate app localizations
|
||||
run: ./flutterw gen-l10n
|
||||
|
||||
- name: Static analysis.
|
||||
run: ./flutterw analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: ./flutterw test
|
||||
|
||||
analyze_codeql:
|
||||
name: CodeQL analysis (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
# Building relies on the Android Gradle plugin,
|
||||
# which requires a modern Java version (not the default one).
|
||||
- name: Set up JDK for Android Gradle plugin
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
# build in profile mode, instead of release,
|
||||
# so that setting up signing environment variables is not required
|
||||
run: |
|
||||
scripts/apply_flavor_play.sh
|
||||
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
85
.github/workflows/release.yml
vendored
85
.github/workflows/release.yml
vendored
|
@ -5,44 +5,32 @@ on:
|
|||
tags:
|
||||
- v*
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
release_github:
|
||||
name: GitHub release
|
||||
build:
|
||||
name: Build and release artifacts.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
egress-policy: audit
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
# Building relies on the Android Gradle plugin,
|
||||
# which requires a modern Java version (not the default one).
|
||||
- name: Set up JDK for Android Gradle plugin
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Get packages for the Flutter project.
|
||||
run: scripts/pub_get_all.sh
|
||||
|
||||
- name: Get Flutter packages
|
||||
run: ./flutterw pub get
|
||||
|
||||
- name: Generate app localizations
|
||||
run: ./flutterw gen-l10n
|
||||
|
||||
- name: Update Flutter version file
|
||||
- name: Update the flutter version file.
|
||||
run: scripts/update_flutter_version.sh
|
||||
|
||||
- name: Build signed artifacts
|
||||
- name: Static analysis.
|
||||
run: ./flutterw analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: ./flutterw test
|
||||
|
||||
- name: Build signed artifacts.
|
||||
# `KEY_JKS` should contain the result of:
|
||||
# gpg -c --armor keystore.jks
|
||||
# `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above
|
||||
|
@ -61,12 +49,13 @@ jobs:
|
|||
cp build/app/outputs/bundle/playRelease/*.aab outputs
|
||||
./flutterw build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders.sksl.json
|
||||
cp build/app/outputs/apk/play/release/*.apk outputs
|
||||
scripts/apply_flavor_huawei.sh
|
||||
./flutterw build apk -t lib/main_huawei.dart --flavor huawei --bundle-sksl-path shaders.sksl.json
|
||||
cp build/app/outputs/apk/huawei/release/*.apk outputs
|
||||
scripts/apply_flavor_izzy.sh
|
||||
./flutterw build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi
|
||||
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
||||
scripts/apply_flavor_libre.sh
|
||||
./flutterw build appbundle -t lib/main_libre.dart --flavor libre
|
||||
cp build/app/outputs/bundle/libreRelease/*.aab outputs
|
||||
./flutterw build apk -t lib/main_libre.dart --flavor libre --split-per-abi
|
||||
cp build/app/outputs/apk/libre/release/*.apk outputs
|
||||
rm $AVES_STORE_FILE
|
||||
|
@ -76,45 +65,35 @@ jobs:
|
|||
AVES_KEY_ALIAS: ${{ secrets.AVES_KEY_ALIAS }}
|
||||
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
|
||||
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
||||
AVES_HUAWEI_API_KEY: ${{ secrets.AVES_HUAWEI_API_KEY }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
||||
with:
|
||||
subject-path: 'outputs/*'
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
||||
- name: Create a release with the APK and App Bundle.
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "outputs/*"
|
||||
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload app bundle
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: appbundle
|
||||
path: outputs/app-play-release.aab
|
||||
|
||||
release_play:
|
||||
name: Play Store beta release
|
||||
needs: [ release_github ]
|
||||
release:
|
||||
name: Create beta release on Play Store.
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get appbundle from artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- name: Get appbundle from artifacts.
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: appbundle
|
||||
|
||||
- name: Release to beta channel
|
||||
uses: r0adkll/upload-google-play@935ef9c68bb393a8e6116b1575626a7f5be3a7fb # v1.1.3
|
||||
- name: Release app to beta channel.
|
||||
uses: r0adkll/upload-google-play@v1.1.1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
|
||||
packageName: deckers.thibault.aves
|
||||
|
|
76
.github/workflows/scorecards.yml
vendored
76
.github/workflows/scorecards.yml
vendored
|
@ -1,76 +0,0 @@
|
|||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '20 7 * * 2'
|
||||
push:
|
||||
branches: ["develop"]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecards on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
with:
|
||||
sarif_file: results.sarif
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -5,11 +5,9 @@
|
|||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
|
@ -29,6 +27,7 @@ migrate_working_dir/
|
|||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
@ -47,6 +46,3 @@ app.*.map.json
|
|||
# screenshot generation
|
||||
/test_driver/assets/screenshots/
|
||||
/screenshots/
|
||||
|
||||
# generated files
|
||||
/lib/l10ngen/app_localizations*
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
repos:
|
||||
- repo: https://github.com/gherynos/pre-commit-java
|
||||
rev: v0.2.4
|
||||
hooks:
|
||||
- id: Checkstyle
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.16.3
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
rev: 3.0.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "aves (main play)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main_play.dart",
|
||||
"args": [
|
||||
"--flavor",
|
||||
"play"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "aves (main play) [profile]",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"program": "lib/main_play.dart",
|
||||
"args": [
|
||||
"--flavor",
|
||||
"play"
|
||||
],
|
||||
"flutterMode": "profile"
|
||||
}
|
||||
]
|
||||
}
|
538
CHANGELOG.md
538
CHANGELOG.md
|
@ -4,543 +4,6 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <a id="unreleased"></a>[Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Info: show matching dynamic albums
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when decoding some large thumbnails
|
||||
|
||||
## <a id="v1.13.2"></a>[v1.13.2] - 2025-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- downgraded Flutter to stable v3.27.4
|
||||
- prevent display orientation flip when device rotation is locked
|
||||
|
||||
### Fixed
|
||||
|
||||
- moved file losing its extension and no longer being detected as media in some cases
|
||||
- opening home when launching app as media picker
|
||||
- removing groups with obsolete albums
|
||||
- loading group custom covers
|
||||
- crash when parsing some large media with trailing thumbnail
|
||||
|
||||
## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- albums: show groups to move/copy/export items
|
||||
- albums: hide grouped albums containing hidden items only
|
||||
|
||||
## <a id="v1.13.0"></a>[v1.13.0] - 2025-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Albums: groups
|
||||
- Collection: sort by storage path
|
||||
- Search: week day filters
|
||||
|
||||
### Changed
|
||||
|
||||
- revert to Skia rendering engine
|
||||
|
||||
## <a id="v1.12.10"></a>[v1.12.10] - 2025-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Search: format filters
|
||||
- Albums: sort by path
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.29.3
|
||||
|
||||
### Fixed
|
||||
|
||||
- region decoding failing to access decoder pool
|
||||
|
||||
## <a id="v1.12.9"></a>[v1.12.9] - 2025-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Kannada translation (thanks Chethan, Prasannakumar T Bhat)
|
||||
|
||||
### Changed
|
||||
|
||||
- enable Impeller rendering engine
|
||||
|
||||
### Fixed
|
||||
|
||||
- memory pressure during browsing
|
||||
|
||||
## <a id="v1.12.8"></a>[v1.12.8] - 2025-03-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- swiping images for some combinations of screen size, device pixel ratio, and image size
|
||||
|
||||
## <a id="v1.12.7"></a>[v1.12.7] - 2025-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- handle launch error to report and export DB
|
||||
|
||||
### Changed
|
||||
|
||||
- DB post-upgrade sanitization
|
||||
- upgraded Flutter to stable v3.29.2
|
||||
|
||||
## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- data loss when editing metadata of items with incorrect mime types
|
||||
- metadata inconsistency in the DB due to v1.12.4 upgrade
|
||||
|
||||
## <a id="v1.12.5"></a>[v1.12.5] - 2025-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- support for Samsung HEIC motion photos embedding video in sefd box
|
||||
- Cataloguing: identify video location from Apple QuickTime metadata, and 3GPP `loci` atom
|
||||
- Collection: stack RAW and HEIC with same file names
|
||||
- display home tile in side drawer when customized
|
||||
- Galician translation (thanks Rubén Castiñeiras Lorenzo)
|
||||
|
||||
### Changed
|
||||
|
||||
- increased precision of file modified date to milliseconds
|
||||
- upgraded Flutter to stable v3.29.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- opening motion photo embedded video when video track is not the first one
|
||||
- some SVG rendering issues
|
||||
- decoding of SVG containing references to namespaces in !ATTLIST
|
||||
- fallback decoding of images packed in RGBA_1010102 config
|
||||
|
||||
## <a id="v1.12.4"></a>[v1.12.4] - 2025-03-05 [YANKED]
|
||||
|
||||
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
|
||||
|
||||
### Added
|
||||
|
||||
- Metadata: edit location via GPX
|
||||
- Metadata: toggle for all types in removal dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: improved subsampling and filter quality strategy
|
||||
- Collection: ignore moving an item to its current directory
|
||||
- Collection: keep selection when action on several items is interrupted before processing
|
||||
- Collection: preserve favourite status when converting items
|
||||
- upgraded Flutter to stable v3.27.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- editing TIFF metadata increasing file size
|
||||
- region decoding for some RAW files
|
||||
- incorrect video size or orientation as reported by Media Store
|
||||
- corrupting image when removing video from motion photo with incorrect metadata
|
||||
|
||||
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- DDM coordinate format option
|
||||
|
||||
### Changed
|
||||
|
||||
- Video: use `media-kit` instead of `ffmpeg-kit` for metadata fetch
|
||||
- Info: show video chapters
|
||||
- Accessibility: apply system "touch and hold delay" setting
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when cataloguing some videos
|
||||
- switching to PiP for any inactive app state
|
||||
|
||||
## <a id="v1.12.1"></a>[v1.12.1] - 2025-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- dynamic album decompose action
|
||||
- Danish translation (thanks Grooty12, Victor M, cat)
|
||||
|
||||
### Fixed
|
||||
|
||||
- analysis service not triggering because of uninitialized app lifecycle
|
||||
- Viewer: displaying neighbour items when the initial item of a view intent is a new one
|
||||
- Search: dynamic album name filtering
|
||||
|
||||
## <a id="v1.12.0"></a>[v1.12.0] - 2024-12-19
|
||||
|
||||
### Added
|
||||
|
||||
- Countries: show states for Mexico
|
||||
- Estonian translation (thanks Priit Jõerüüt)
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.27.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when loading many new items on low memory devices
|
||||
|
||||
## <a id="v1.11.20"></a>[v1.11.20] - 2024-12-11
|
||||
|
||||
### Added
|
||||
|
||||
- Albums: dynamic albums from filter sets
|
||||
- Bulgarian translation (thanks Petrov)
|
||||
- Tamil translation (thanks தமிழ்நேரம்)
|
||||
|
||||
## <a id="v1.11.19"></a>[v1.11.19] - 2024-11-24
|
||||
|
||||
### Added
|
||||
|
||||
- integrate with OS app language settings on Android >=14
|
||||
|
||||
### Changed
|
||||
|
||||
- remember title filter visibility by page
|
||||
|
||||
## <a id="v1.11.18"></a>[v1.11.18] - 2024-11-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Albums: improved album creation feedback
|
||||
- upgraded Flutter to stable v3.24.5
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when playing video with DCL restriction enabled
|
||||
- cataloguing images with wrong MPF offsets
|
||||
- printing multi-page items containing some unprintable pages
|
||||
- English (Shavian) locale tags for store listing
|
||||
|
||||
## <a id="v1.11.17"></a>[v1.11.17] - 2024-10-30
|
||||
|
||||
### Added
|
||||
|
||||
- Map: create shortcut to custom region and filters
|
||||
- Video: frame stepping forward/backward
|
||||
- Video: custom playback buttons
|
||||
- English (Shavian) translation (thanks Paranoid Android)
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.24.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when loading large collection
|
||||
- Viewer: copying content URI item
|
||||
- Albums: creating album with same name as existing empty directory
|
||||
- Privacy: tagging while vaults are unlocked does not yield recent tags visible when vaults are locked
|
||||
|
||||
## <a id="v1.11.16"></a>[v1.11.16] - 2024-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- case-insensitive access to restricted directories
|
||||
|
||||
## <a id="v1.11.15"></a>[v1.11.15] - 2024-10-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Enterprise: do not request `INTERACT_ACROSS_PROFILES` permission (Play Store compatibility)
|
||||
|
||||
## <a id="v1.11.14"></a>[v1.11.14] - 2024-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- Map: OpenTopoMap raster layer
|
||||
- Map: OSM Liberty vector layer (hosted by OSM Americana)
|
||||
- Interoperability: receiving `geo:` URI generally opens map page at location
|
||||
- Interoperability: receiving `geo:` URI when editing item location fills in coordinates
|
||||
- Map basic app shortcut
|
||||
- Enterprise: support for work profile switching from the drawer
|
||||
- Settings: hidden path filters are merged with others and can be toggled
|
||||
|
||||
### Removed
|
||||
|
||||
- `Safe mode` basic app shortcut
|
||||
|
||||
### Fixed
|
||||
|
||||
- hanging when cataloguing some JPEG MPF images
|
||||
- Apple HDR image detection
|
||||
|
||||
## <a id="v1.11.13"></a>[v1.11.13] - 2024-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- support opening from the lock screen
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.24.3
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when cataloguing some malformed MP4 files
|
||||
- inconsistent launch screen
|
||||
|
||||
## <a id="v1.11.12"></a>[v1.11.12] - 2024-09-16 [YANKED AGAIN!]
|
||||
|
||||
## <a id="v1.11.11"></a>[v1.11.11] - 2024-09-16 [YANKED]
|
||||
|
||||
## <a id="v1.11.10"></a>[v1.11.10] - 2024-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Swedish translation (thanks Shift18, Andreas Håll)
|
||||
|
||||
### Changed
|
||||
|
||||
- request notification permission when launching scanning service
|
||||
- upgraded Flutter to stable v3.24.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- duplicates from new item loading/refreshing
|
||||
|
||||
## <a id="v1.11.9"></a>[v1.11.9] - 2024-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: display more items in tag/copy/move quick action choosers
|
||||
- Viewer: long descriptions are scrollable when overlay is expanded by tap
|
||||
- Collection: sort by duration
|
||||
- Map: open external map app from map views
|
||||
- Explorer: stats
|
||||
|
||||
### Changed
|
||||
|
||||
- Accessibility: more animations and effects are suppressed when animations are disabled
|
||||
- upgraded Flutter to stable v3.24.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- opening app from launcher always showing home page
|
||||
- collection quick actions not showing in the top bar nor the menu
|
||||
- multiple widget setup after device reboot
|
||||
|
||||
## <a id="v1.11.8"></a>[v1.11.8] - 2024-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- Explorer: set custom path as home
|
||||
- Explorer: create shortcut to custom path
|
||||
|
||||
### Changed
|
||||
|
||||
- target Android 15 (API 35)
|
||||
- upgraded Flutter to stable v3.22.3
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when cataloguing some PNG files
|
||||
|
||||
## <a id="v1.11.7"></a>[v1.11.7] - 2024-07-18 [YANKED AGAIN!]
|
||||
|
||||
## <a id="v1.11.6"></a>[v1.11.6] - 2024-07-17 [YANKED]
|
||||
|
||||
## <a id="v1.11.5"></a>[v1.11.5] - 2024-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: stack RAW and JPEG with same file names
|
||||
- Collection: ask to rename/replace/skip when converting items with name conflict
|
||||
- Export: bulk converting motion photos to still images
|
||||
- Explorer: view folder tree and filter paths
|
||||
|
||||
### Fixed
|
||||
|
||||
- switching to PiP when changing device orientation on Android >=13
|
||||
- handling wallpaper intent without URI
|
||||
- sizing widgets with some launchers on Android >=12
|
||||
|
||||
### Removed
|
||||
|
||||
- `huawei` app flavor
|
||||
|
||||
## <a id="v1.11.4"></a>[v1.11.4] - 2024-07-09 [YANKED]
|
||||
|
||||
## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- handle `MediaStore.ACTION_REVIEW` intent
|
||||
|
||||
## <a id="v1.11.2"></a>[v1.11.2] - 2024-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- Albums / Countries / Tags: show selection in Collection
|
||||
- allow shifting dates by seconds
|
||||
|
||||
### Changed
|
||||
|
||||
- opening app from launcher shows home page only when exited by back button
|
||||
- Screen saver: black background, consistent with slideshow
|
||||
- upgraded Flutter to stable v3.22.2
|
||||
|
||||
### Removed
|
||||
|
||||
- support for Android KitKat (API 19)
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when cataloguing large images
|
||||
|
||||
## <a id="v1.11.1"></a>[v1.11.1] - 2024-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- Cataloguing: identify Apple variant of HDR images
|
||||
- Collection: `select all` available as quick action
|
||||
- Collection: allow using hash (md5/sha1/sha256) when bulk renaming
|
||||
- Info: color palette
|
||||
- Video: external subtitle support (SRT)
|
||||
- option to force using western arabic numerals for dates
|
||||
- Persian translation (thanks امیر جهانگرد, slasb37, mimvahedi, Alireza Rashidi)
|
||||
|
||||
### Changed
|
||||
|
||||
- logo
|
||||
- upgraded Flutter to stable v3.19.6
|
||||
|
||||
### Fixed
|
||||
|
||||
- rendering of SVG with large header
|
||||
- stopping video playback when changing device orientation on Android >=13
|
||||
- printing content orientation according to page format
|
||||
|
||||
## <a id="v1.11.0"></a>[v1.11.0] - 2024-05-01 [YANKED]
|
||||
|
||||
## <a id="v1.10.9"></a>[v1.10.9] - 2024-04-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- rendering of SVG with viewbox offset
|
||||
- superfluous media store reinitialization when relaunching app from launcher
|
||||
|
||||
## <a id="v1.10.8"></a>[v1.10.8] - 2024-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: support for Fairphone burst pattern
|
||||
- Collection: allow using tags/make/model when bulk renaming
|
||||
- Video: A-B repeat
|
||||
- Settings: hidden items can be toggled
|
||||
|
||||
### Changed
|
||||
|
||||
- opening app from launcher always show home page
|
||||
- use dates with western arabic numerals for maghreb arabic locales
|
||||
- album unique names are case insensitive
|
||||
- upgraded Flutter to stable v3.19.5
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when decoding large region
|
||||
- viewer position drift during scale
|
||||
- viewer side gesture precedence (next entry by single tap vs zoom by double tap)
|
||||
|
||||
## <a id="v1.10.7"></a>[v1.10.7] - 2024-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Cataloguing: detect/filter HDR videos
|
||||
|
||||
### Changed
|
||||
|
||||
- check Media Store changes when resuming app
|
||||
- disabling animations also applies to pop up menus
|
||||
- upgraded Flutter to stable v3.19.3
|
||||
|
||||
### Fixed
|
||||
|
||||
- engine leak from analysis worker
|
||||
|
||||
## <a id="v1.10.6"></a>[v1.10.6] - 2024-03-11 [YANKED]
|
||||
|
||||
## <a id="v1.10.5"></a>[v1.10.5] - 2024-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: prompt to show newly edited item
|
||||
- Widget: outline color options according to device theme
|
||||
- Catalan translation (thanks Marc Amorós)
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.19.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- untracked binned items recovery
|
||||
- untracked vault items recovery
|
||||
|
||||
## <a id="v1.10.4"></a>[v1.10.4] - 2024-02-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- motion photo detection for xml variant of google container item
|
||||
- HEIF size detection for some corrupted files
|
||||
- viewer transition direction & effects for RTL locales
|
||||
|
||||
## <a id="v1.10.3"></a>[v1.10.3] - 2024-01-29
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: optional histogram (for real this time)
|
||||
- Collection: allow hiding thumbnail overlay HDR icon
|
||||
- Collection: allow setting any filtered collection as home page
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: lift format control for tiling, allowing large DNG tiling if supported
|
||||
- Info: strip `unlocated` filter from context collection when editing location via map
|
||||
- Slideshow: keep playing when losing focus but app is still visible (e.g. split screen)
|
||||
- upgraded Flutter to stable v3.16.9
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when loading some large DNG in viewer
|
||||
- searching from drawer on mobile
|
||||
- resizing TIFF during conversion
|
||||
|
||||
## <a id="v1.10.2"></a>[v1.10.2] - 2023-12-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: keep controls in the lower right corner even with RTL locales
|
||||
|
||||
### Fixed
|
||||
|
||||
- crash when loading SVG defined with large dimensions
|
||||
|
||||
## <a id="v1.10.1"></a>[v1.10.1] - 2023-12-21
|
||||
|
||||
### Added
|
||||
|
||||
- Cataloguing: detect/filter `Ultra HDR`
|
||||
- Viewer: show JPEG MPF dependent images (except thumbnails and HDR gain maps)
|
||||
- Info: show metadata from JPEG MPF
|
||||
- Info: open images embedded via JPEG MPF
|
||||
- Arabic translation (thanks Mohamed Zeroug)
|
||||
- Belarusian translation (thanks Макар Разин)
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.16.5
|
||||
|
||||
## <a id="v1.10.0"></a>[v1.10.0] - 2023-12-02
|
||||
|
||||
### Added
|
||||
|
@ -1397,7 +860,6 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
- app launching on some devices
|
||||
- corrupting motion photo exif editing (e.g. rotation)
|
||||
- accessing files in `Download` directory when not using reference case
|
||||
|
||||
## [v1.4.9] - 2021-08-20
|
||||
|
||||
|
|
95
README.md
95
README.md
|
@ -12,6 +12,12 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
||||
alt='Get it on Google Play'
|
||||
height="80">](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1)
|
||||
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/huawei-appgallery-badge-english-black.png"
|
||||
alt='Get it on Huawei AppGallery'
|
||||
height="80">](https://appgallery.huawei.com/app/C106014023)
|
||||
[<img src="https://raw.githubusercontent.com/deckerst/common/main/assets/amazon-appstore-badge-english-black.png"
|
||||
alt='Get it on Amazon Appstore'
|
||||
height="80">](https://www.amazon.com/dp/B09XQHQQ72)
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt='Get it on IzzyOnDroid'
|
||||
height="80">](https://apt.izzysoft.de/fdroid/index/apk/deckers.thibault.aves)
|
||||
|
@ -35,7 +41,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
|
|||
|
||||
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||
|
||||
Aves integrates with Android (including Android TV) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
|
||||
Aves integrates with Android (from KitKat to Android 13, including Android TV) with features such as **widgets**, **app shortcuts**, **screen saver** and **global search** handling. It also works as a **media viewer and picker**.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
@ -111,96 +117,17 @@ Some users have expressed the wish to financially support the project. Thanks!
|
|||
|
||||
## Project Setup
|
||||
|
||||
### Install dependencies
|
||||
|
||||
Before running or building the app, update the dependencies for the desired flavor:
|
||||
```
|
||||
scripts/apply_flavor_play.sh
|
||||
# scripts/apply_flavor_play.sh
|
||||
```
|
||||
|
||||
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
||||
|
||||
### To run the app:
|
||||
To run the app:
|
||||
```
|
||||
./flutterw run -t lib/main_play.dart --flavor play
|
||||
```
|
||||
### To build the app:
|
||||
|
||||
creare file con le tue credenziali file.keystore
|
||||
|
||||
dove YOUR_ALIAS_NAME è il tuo unico alias name
|
||||
|
||||
e YOUR_ALIAS_PWD è la password del tuo alias
|
||||
```sh
|
||||
keytool -genkey -v -keystore file.keystore -alias YOUR_ALIAS_NAME -storepass YOUR_ALIAS_PWD -keypass YOUR_ALIAS_PWD -keyalg RSA -validity 36500
|
||||
```
|
||||
in questo caso ho inserito
|
||||
```sh
|
||||
cd android
|
||||
keytool -genkey -v -keystore file.keystore -alias FabioMich66 -storepass Master66 -keypass Master66 -keyalg RSA -validity 36500
|
||||
```
|
||||
se non puoi eseguire keytool perchè non è nel path di sistema cercalo usando
|
||||
```sh
|
||||
cd /
|
||||
sudo find -name keytool
|
||||
```
|
||||
compilare il file `<app dir>/android/key.properties`
|
||||
```
|
||||
nano android/key.properties
|
||||
```
|
||||
questi i miei dati utilizzando il format key_template.properties
|
||||
```
|
||||
storeFile=/Users/fabio/flutter_apps/aves/android/file.keystore
|
||||
storePassword=Master66
|
||||
keyAlias=FabioMich66
|
||||
keyPassword=Master66
|
||||
googleApiKey=<GOOGLE_API_KEY>
|
||||
```
|
||||
infine compilare l'apk
|
||||
```
|
||||
./flutterw build apk -t lib/main_play.dart --flavor play
|
||||
# ./flutterw run -t lib/main_play.dart --flavor play
|
||||
```
|
||||
|
||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop
|
||||
|
||||
## Android studio
|
||||
|
||||
caricare il file da github selezionando le mnù a tendina File-New-project from Version Control
|
||||
|
||||
selezionare version control tipo: git
|
||||
|
||||
inserire URL di aves
|
||||
|
||||
https://github.com/deckerst/aves
|
||||
|
||||
flaggare shallow clone with history troncated 1 commits
|
||||
|
||||
aprire la console sulla dir aves appena creata e caricare le dipendenze
|
||||
|
||||
```
|
||||
scripts/apply_flavor_izzy.sh
|
||||
```
|
||||
in settings - Languages and Framework - Dart inserire il path
|
||||
|
||||
```
|
||||
/home/fabio/flutter/bin/cache/
|
||||
```
|
||||
e spuntare project aves
|
||||
|
||||
Edit configurations e aggiungere shell script con un nome x es izzi
|
||||
|
||||
poi flaggare script text e inserire
|
||||
|
||||
./flutterw run -t lib/main_izzy.dart --flavor izzy
|
||||
|
||||
la working directory sarà una cosa così
|
||||
|
||||
/home/fabio/StudioProjects/aves
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/check.yml?branch=develop
|
||||
|
|
|
@ -9,11 +9,6 @@ analyzer:
|
|||
# implicit-casts: false
|
||||
# implicit-dynamic: false
|
||||
|
||||
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
|
||||
formatter:
|
||||
page_width: 240
|
||||
trailing_commas: preserve
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# from 'flutter_lints', excluded
|
||||
|
@ -41,8 +36,3 @@ linter:
|
|||
prefer_single_quotes: true
|
||||
sort_child_properties_last: true
|
||||
unawaited_futures: true
|
||||
|
||||
# `const` related, included
|
||||
prefer_const_constructors: true
|
||||
prefer_const_literals_to_create_immutables: true
|
||||
prefer_const_declarations: true
|
||||
|
|
5
android/.gitignore
vendored
5
android/.gitignore
vendored
|
@ -5,12 +5,9 @@ gradle-wrapper.jar
|
|||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
.kotlin/
|
||||
/build/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
|
|
@ -1,13 +1,31 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'com.google.devtools.ksp' version "$ksp_version"
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'dev.flutter.flutter-gradle-plugin'
|
||||
}
|
||||
|
||||
def packageName = "deckers.thibault.aves"
|
||||
|
||||
// Flutter properties
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
// Keys
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
|
@ -26,23 +44,50 @@ if (keystorePropertiesFile.exists()) {
|
|||
keystoreProperties["keyAlias"] = System.getenv("AVES_KEY_ALIAS") ?: "<NONE>"
|
||||
keystoreProperties["keyPassword"] = System.getenv("AVES_KEY_PASSWORD") ?: "<NONE>"
|
||||
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain 17
|
||||
keystoreProperties["huaweiApiKey"] = System.getenv("AVES_HUAWEI_API_KEY") ?: "<NONE>"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = 'deckers.thibault.aves'
|
||||
compileSdk = 36
|
||||
namespace 'deckers.thibault.aves'
|
||||
compileSdk 34
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
checkAllWarnings true
|
||||
warningsAsErrors true
|
||||
disable 'InvalidPackage'
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
// The Amazon Developer console mistakenly considers the app to not be 64-bit compatible
|
||||
// if there are some libs in `lib/armeabi-v7a` unmatched by libs in `lib/arm64-v8a`,
|
||||
// so we exclude the extra `neon` libs bundled by `FFmpegKit`.
|
||||
exclude 'lib/armeabi-v7a/*_neon.so'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId packageName
|
||||
minSdk flutter.minSdkVersion
|
||||
targetSdk 36
|
||||
versionCode flutter.versionCode
|
||||
versionName flutter.versionName
|
||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
||||
// minSdk constraints:
|
||||
// - Flutter & other plugins: 16
|
||||
// - google_maps_flutter v2.1.1: 20
|
||||
// - to build XML documents from XMP data, `metadata-extractor` and `PixyMeta` rely on `DocumentBuilder`,
|
||||
// which implementation `DocumentBuilderImpl` is provided by the OS and is not customizable on Android,
|
||||
// but the implementation on API <19 is not robust enough and fails to build XMP documents
|
||||
minSdk 19
|
||||
targetSdk 34
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>",
|
||||
huaweiApiKey: keystoreProperties["huaweiApiKey"] ?: "<NONE>"]
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
|
@ -65,6 +110,13 @@ android {
|
|||
ext.useNdkAbiFilters = true
|
||||
}
|
||||
|
||||
huawei {
|
||||
// Huawei AppGallery
|
||||
dimension "store"
|
||||
// generate a universal APK without x86 native libs
|
||||
ext.useNdkAbiFilters = true
|
||||
}
|
||||
|
||||
izzy {
|
||||
// IzzyOnDroid
|
||||
// check offending libraries with `scanapk`
|
||||
|
@ -128,20 +180,29 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.withType(KotlinCompile).configureEach {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(8)
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = 'https://jitpack.io'
|
||||
url 'https://jitpack.io'
|
||||
content {
|
||||
includeGroup "com.github.deckerst"
|
||||
includeGroup "com.github.deckerst.mp4parser"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url = 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||
url 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||
content {
|
||||
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
||||
}
|
||||
|
@ -149,38 +210,39 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
implementation "androidx.appcompat:appcompat:1.7.1"
|
||||
implementation 'androidx.core:core-ktx:1.16.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.9.1'
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-beta01'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
// SLF4J implementation for `mp4parser`
|
||||
implementation 'org.slf4j:slf4j-simple:2.0.17'
|
||||
implementation 'org.slf4j:slf4j-simple:2.0.9'
|
||||
|
||||
// forked, built by JitPack:
|
||||
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
// - https://jitpack.io/p/deckerst/androidsvg
|
||||
// - https://jitpack.io/p/deckerst/mp4parser
|
||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:d6b2b0aa4f'
|
||||
implementation 'com.github.deckerst:androidsvg:67db933051'
|
||||
implementation 'com.github.deckerst.mp4parser:isoparser:c2898f1832'
|
||||
implementation 'com.github.deckerst.mp4parser:muxer:c2898f1832'
|
||||
implementation 'com.github.deckerst:pixymeta-android:cb1cdc932e'
|
||||
implementation project(':exifinterface')
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4'
|
||||
implementation 'com.github.deckerst.mp4parser:isoparser:4cc0c5d06c'
|
||||
implementation 'com.github.deckerst.mp4parser:muxer:4cc0c5d06c'
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
|
||||
// huawei flavor only
|
||||
huaweiImplementation "com.huawei.agconnect:agconnect-core:$huawei_agconnect_version"
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.9.1'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.7.0'
|
||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||
|
||||
compileOnly rootProject.findProject(':streams_channel')
|
||||
|
@ -191,3 +253,8 @@ if (useCrashlytics) {
|
|||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'com.google.firebase.crashlytics'
|
||||
}
|
||||
|
||||
if (useHms) {
|
||||
println("Building flavor with HMS plugin")
|
||||
apply plugin: 'com.huawei.agconnect'
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_flavour">#815AFA</color>
|
||||
<color name="ic_launcher_flavour">#7B1FA2</color>
|
||||
</resources>
|
|
@ -7,13 +7,14 @@
|
|||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<!--
|
||||
TODO TLAD [Android 14 (API 34)] request/handle READ_MEDIA_VISUAL_USER_SELECTED permission
|
||||
cf https://developer.android.com/about/versions/14/changes/partial-photo-video-access
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
@ -31,13 +32,9 @@
|
|||
|
||||
<!-- to access media with original metadata with scoped storage (API >=29) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<!-- to provide a foreground service type, as required from Android 14 (API 34) -->
|
||||
<!-- to provide a foreground service type, as required by Android 14 (API 34) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
android:maxSdkVersion="34"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
<!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
@ -71,12 +68,14 @@
|
|||
-->
|
||||
|
||||
<!--
|
||||
allow install on API 21, despite the `minSdk` declared in dependencies:
|
||||
allow install on API 19, despite the `minSdk` declared in dependencies:
|
||||
- Google Maps is from API 20
|
||||
- the Security library is from API 21
|
||||
- FFmpegKit for Flutter is from API 24 (when not LTS)
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="com.arthenica.ffmpegkit.flutter" />
|
||||
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps, androidx.security:security-crypto, com.arthenica.ffmpegkit.flutter" />
|
||||
|
||||
<!-- from Android 11 (API 30), we should define <queries> to make other apps visible to this app -->
|
||||
<!-- from Android 11, we should define <queries> to make other apps visible to this app -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -92,7 +91,7 @@
|
|||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<!--
|
||||
from Android 11 (API 30), `url_launcher` method `canLaunchUrl()` will return false,
|
||||
from Android 11, `url_launcher` method `canLaunchUrl()` will return false,
|
||||
if appropriate intents are not declared, cf https://pub.dev/packages/url_launcher#configuration=
|
||||
-->
|
||||
<!-- to open https URLs -->
|
||||
|
@ -103,7 +102,7 @@
|
|||
</queries>
|
||||
|
||||
<!--
|
||||
as of Flutter v3.22.2, predictive back gesture does not work
|
||||
as of Flutter v3.16.0, predictive back gesture does not work
|
||||
as expected when extending `FlutterFragmentActivity`
|
||||
so we disable `enableOnBackInvokedCallback`
|
||||
-->
|
||||
|
@ -119,7 +118,6 @@
|
|||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
@ -141,8 +139,6 @@
|
|||
<action android:name="android.intent.action.PICK" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.provider.action.REVIEW" />
|
||||
<action android:name="android.provider.action.REVIEW_SECURE" />
|
||||
<action android:name="com.android.camera.action.REVIEW" />
|
||||
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
|
||||
|
||||
|
@ -162,8 +158,6 @@
|
|||
<action android:name="android.intent.action.PICK" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.provider.action.REVIEW" />
|
||||
<action android:name="android.provider.action.REVIEW_SECURE" />
|
||||
<action android:name="com.android.camera.action.REVIEW" />
|
||||
<action android:name="com.android.camera.action.SPLIT_SCREEN_REVIEW" />
|
||||
|
||||
|
@ -179,13 +173,6 @@
|
|||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:scheme="geo" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
|
@ -266,14 +253,10 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!--
|
||||
anonymous service for analysis worker is specified here to provide service type:
|
||||
- `dataSync` for Android 14 (API 34)
|
||||
- `mediaProcessing` from Android 15 (API 35)
|
||||
-->
|
||||
<!-- anonymous service for analysis worker is specified here to provide service type -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync|mediaProcessing"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
|
@ -321,6 +304,9 @@
|
|||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${googleApiKey}" />
|
||||
<meta-data
|
||||
android:name="deckers.thibault.aves.huawei.API_KEY"
|
||||
android:value="${huaweiApiKey}" />
|
||||
<meta-data
|
||||
android:name="firebase_crashlytics_collection_enabled"
|
||||
android:value="false" />
|
||||
|
@ -329,6 +315,8 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- as of Flutter v3.16.0 (stable),
|
||||
Impeller fails to render videos & platform views, has poor performance -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
|
|
|
@ -14,10 +14,8 @@ import androidx.work.ForegroundInfo
|
|||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||
|
@ -28,10 +26,6 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
@ -39,39 +33,24 @@ import kotlin.coroutines.resumeWithException
|
|||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class AnalysisWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var workCont: Continuation<Any?>? = null
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
private var backgroundChannel: MethodChannel? = null
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i(LOG_TAG, "Start analysis worker $id")
|
||||
defaultScope.launch {
|
||||
// prevent ANR triggered by slow operations in main thread
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo())
|
||||
}.join()
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo())
|
||||
suspendCoroutine { cont ->
|
||||
workCont = cont
|
||||
onStart()
|
||||
}
|
||||
dispose()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun dispose() {
|
||||
Log.i(LOG_TAG, "Clean analysis worker $id")
|
||||
flutterEngine?.let {
|
||||
FlutterUtils.runOnUiThread {
|
||||
it.destroy()
|
||||
}
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStart() {
|
||||
Log.i(LOG_TAG, "Start analysis worker")
|
||||
runBlocking {
|
||||
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
|
||||
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
|
||||
flutterEngine = it
|
||||
}
|
||||
}
|
||||
|
@ -79,15 +58,14 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
|||
try {
|
||||
initChannels(applicationContext)
|
||||
|
||||
val preferences = applicationContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
val entryIdStrings = preferences.getStringSet(PREF_ENTRY_IDS_KEY, null)
|
||||
|
||||
runBlocking {
|
||||
FlutterUtils.runOnUiThread {
|
||||
backgroundChannel?.invokeMethod(
|
||||
"start", hashMapOf(
|
||||
"entryIds" to entryIdStrings?.map { Integer.parseUnsignedInt(it) }?.toList(),
|
||||
"entryIds" to inputData.getIntArray(KEY_ENTRY_IDS)?.toList(),
|
||||
"force" to inputData.getBoolean(KEY_FORCE, false),
|
||||
"progressTotal" to inputData.getInt(KEY_PROGRESS_TOTAL, 0),
|
||||
"progressOffset" to inputData.getInt(KEY_PROGRESS_OFFSET, 0),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -110,7 +88,6 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
|||
// - need Context
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(context))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(context))
|
||||
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(context))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(context))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(context))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(context))
|
||||
|
@ -133,7 +110,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
|||
result.success(null)
|
||||
}
|
||||
|
||||
"updateNotification" -> defaultScope.launch { safeSuspend(call, result, ::updateNotification) }
|
||||
"updateNotification" -> {
|
||||
val title = call.argument<String>("title")
|
||||
val message = call.argument<String>("message")
|
||||
setForegroundAsync(createForegroundInfo(title, message))
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
workCont?.resume(null)
|
||||
|
@ -166,42 +148,40 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
|||
applicationContext.getString(R.string.analysis_notification_action_stop),
|
||||
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id)
|
||||
).build()
|
||||
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
|
||||
val contentTitle = title ?: applicationContext.getText(R.string.analysis_notification_default_title)
|
||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL)
|
||||
.setContentTitle(contentTitle)
|
||||
.setTicker(contentTitle)
|
||||
.setContentText(message)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setSmallIcon(icon)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(openAppIntent)
|
||||
.addAction(stopAction)
|
||||
.build()
|
||||
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
|
||||
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= 35 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
|
||||
Build.VERSION.SDK_INT == 34 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
else -> ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
return if (Build.VERSION.SDK_INT >= 34) {
|
||||
// from Android 14 (API 34), foreground service type is mandatory
|
||||
// despite the sample code omitting it at:
|
||||
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
ForegroundInfo(NOTIFICATION_ID, notification, type)
|
||||
} else {
|
||||
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateNotification(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<String>("title")
|
||||
val message = call.argument<String>("message")
|
||||
setForeground(createForegroundInfo(title, message))
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
|
||||
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
|
||||
const val SHARED_PREFERENCES_KEY = "analysis_service"
|
||||
const val PREF_CALLBACK_HANDLE_KEY = "callback_handle"
|
||||
const val PREF_ENTRY_IDS_KEY = "entry_ids"
|
||||
const val CALLBACK_HANDLE_KEY = "callback_handle"
|
||||
|
||||
const val NOTIFICATION_CHANNEL = "analysis"
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
const val KEY_ENTRY_IDS = "entry_ids"
|
||||
const val KEY_FORCE = "force"
|
||||
const val KEY_PROGRESS_TOTAL = "progress_total"
|
||||
const val KEY_PROGRESS_OFFSET = "progress_offset"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,8 @@ package deckers.thibault.aves
|
|||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
|
@ -11,6 +12,9 @@ class HomeWidgetSettingsActivity : MainActivity() {
|
|||
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (FlutterUtils.isSoftwareRenderingRequired()) {
|
||||
intent.enableSoftwareRendering()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// cancel if user does not complete widget setup
|
||||
|
@ -52,7 +56,7 @@ class HomeWidgetSettingsActivity : MainActivity() {
|
|||
finish()
|
||||
}
|
||||
|
||||
override fun extractIntentData(intent: Intent?): FieldMap {
|
||||
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_WIDGET_SETTINGS,
|
||||
INTENT_DATA_KEY_WIDGET_ID to appWidgetId,
|
||||
|
|
|
@ -8,22 +8,14 @@ import android.content.Intent
|
|||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.SizeF
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.net.toUri
|
||||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
@ -33,14 +25,8 @@ import io.flutter.FlutterInjector
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
@ -54,15 +40,12 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
|||
for (widgetId in appWidgetIds) {
|
||||
val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId)
|
||||
|
||||
val pendingResult = goAsync()
|
||||
defaultScope.launch {
|
||||
val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false)
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps)
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundProps)
|
||||
|
||||
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false)
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
|
||||
|
||||
pendingResult?.finish()
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -78,32 +61,20 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
|||
imageByteFetchJob = defaultScope.launch {
|
||||
delay(500)
|
||||
val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true)
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, imageProps)
|
||||
updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
||||
|
||||
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
|
||||
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@Suppress("DEPRECATION")
|
||||
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (sizes.isNullOrEmpty()) {
|
||||
val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
|
||||
val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT
|
||||
val widthDip = widgetInfo.getInt(widthKey)
|
||||
val heightDip = widgetInfo.getInt(heightKey)
|
||||
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
||||
}
|
||||
|
||||
return sizes
|
||||
private fun getWidgetSizePx(context: Context, widgetInfo: Bundle): Pair<Int, Int> {
|
||||
val devicePixelRatio = getDevicePixelRatio()
|
||||
val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
|
||||
val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT
|
||||
val widthPx = (widgetInfo.getInt(widthKey) * devicePixelRatio).roundToInt()
|
||||
val heightPx = (widgetInfo.getInt(heightKey) * devicePixelRatio).roundToInt()
|
||||
return Pair(widthPx, heightPx)
|
||||
}
|
||||
|
||||
private suspend fun getProps(
|
||||
|
@ -113,155 +84,86 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
|||
drawEntryImage: Boolean,
|
||||
reuseEntry: Boolean = false,
|
||||
): FieldMap? {
|
||||
val sizesDip = getWidgetSizesDip(context, widgetInfo)
|
||||
if (sizesDip.isEmpty()) return null
|
||||
|
||||
val sizeDip = sizesDip.first()
|
||||
if (sizeDip.width == 0f || sizeDip.height == 0f) return null
|
||||
|
||||
val sizesDipMap = sizesDip.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
||||
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
val params = hashMapOf(
|
||||
"widgetId" to widgetId,
|
||||
"sizesDip" to sizesDipMap,
|
||||
"devicePixelRatio" to getDevicePixelRatio(),
|
||||
"drawEntryImage" to drawEntryImage,
|
||||
"reuseEntry" to reuseEntry,
|
||||
"isSystemThemeDark" to isNightModeOn,
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
put("cornerRadiusPx", context.resources.getDimension(android.R.dimen.system_app_widget_background_radius))
|
||||
}
|
||||
}
|
||||
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo)
|
||||
if (widthPx == 0 || heightPx == 0) return null
|
||||
|
||||
initFlutterEngine(context)
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
|
||||
try {
|
||||
val props = suspendCoroutine { cont ->
|
||||
val props = suspendCoroutine<Any?> { cont ->
|
||||
defaultScope.launch {
|
||||
FlutterUtils.runOnUiThread {
|
||||
tryDrawWidget(params, cont, 0)
|
||||
channel.invokeMethod("drawWidget", hashMapOf(
|
||||
"widgetId" to widgetId,
|
||||
"widthPx" to widthPx,
|
||||
"heightPx" to heightPx,
|
||||
"devicePixelRatio" to getDevicePixelRatio(),
|
||||
"drawEntryImage" to drawEntryImage,
|
||||
"reuseEntry" to reuseEntry,
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
cont.resume(result)
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
cont.resumeWithException(Exception("not implemented"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@Suppress("unchecked_cast")
|
||||
return props as FieldMap?
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId sizesPx=$sizesDip", e)
|
||||
Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun tryDrawWidget(params: HashMap<String, Any>, cont: Continuation<Any?>, drawRetry: Int) {
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
|
||||
channel.invokeMethod("drawWidget", params, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
cont.resume(result)
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
if (drawRetry > DRAW_RETRY_MAX) {
|
||||
cont.resumeWithException(Exception("not implemented"))
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
tryDrawWidget(params, cont, drawRetry + 1)
|
||||
}, 2000L)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateWidgetImage(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
widgetId: Int,
|
||||
widgetInfo: Bundle,
|
||||
props: FieldMap?,
|
||||
) {
|
||||
props ?: return
|
||||
|
||||
val bytesBySizeDip = (props["bytesBySizeDip"] as List<*>?)?.mapNotNull {
|
||||
if (it is Map<*, *>) {
|
||||
val widthDip = (it["widthDip"] as Number?)?.toFloat()
|
||||
val heightDip = (it["heightDip"] as Number?)?.toFloat()
|
||||
val bytes = it["bytes"] as ByteArray?
|
||||
if (widthDip != null && heightDip != null && bytes != null) {
|
||||
Pair(SizeF(widthDip, heightDip), bytes)
|
||||
} else null
|
||||
} else null
|
||||
}
|
||||
val bytes = props["bytes"] as ByteArray?
|
||||
val updateOnTap = props["updateOnTap"] as Boolean?
|
||||
if (bytesBySizeDip == null || updateOnTap == null) {
|
||||
if (bytes == null || updateOnTap == null) {
|
||||
Log.e(LOG_TAG, "missing arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if (bytesBySizeDip.isEmpty()) {
|
||||
Log.e(LOG_TAG, "empty image list")
|
||||
return
|
||||
}
|
||||
|
||||
val bitmaps = ArrayList<Bitmap>()
|
||||
|
||||
fun createRemoteViewsForSize(
|
||||
context: Context,
|
||||
widgetId: Int,
|
||||
sizeDip: SizeF,
|
||||
bytes: ByteArray,
|
||||
updateOnTap: Boolean,
|
||||
): RemoteViews? {
|
||||
val devicePixelRatio = getDevicePixelRatio()
|
||||
val widthPx = (sizeDip.width * devicePixelRatio).roundToInt()
|
||||
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
|
||||
|
||||
try {
|
||||
val bitmap = createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
|
||||
bitmaps.add(it)
|
||||
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
||||
}
|
||||
|
||||
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
|
||||
|
||||
return RemoteViews(context.packageName, R.layout.app_widget).apply {
|
||||
setImageViewBitmap(R.id.widget_img, bitmap)
|
||||
setOnClickPendingIntent(R.id.widget_img, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to draw widget", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo)
|
||||
if (widthPx == 0 || heightPx == 0) return
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// multiple rendering for all possible sizes
|
||||
val views = RemoteViews(
|
||||
bytesBySizeDip.associateBy(
|
||||
{ (sizeDip, _) -> sizeDip },
|
||||
{ (sizeDip, bytes) -> createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) },
|
||||
).filterValues { it != null }.mapValues { (_, view) -> view!! }
|
||||
)
|
||||
appWidgetManager.updateAppWidget(widgetId, views)
|
||||
} else {
|
||||
// single rendering
|
||||
val (sizeDip, bytes) = bytesBySizeDip.first()
|
||||
val views = createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap)
|
||||
appWidgetManager.updateAppWidget(widgetId, views)
|
||||
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888)
|
||||
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
||||
|
||||
val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId)
|
||||
|
||||
val views = RemoteViews(context.packageName, R.layout.app_widget).apply {
|
||||
setImageViewBitmap(R.id.widget_img, bitmap)
|
||||
setOnClickPendingIntent(R.id.widget_img, pendingIntent)
|
||||
}
|
||||
|
||||
appWidgetManager.updateAppWidget(widgetId, views)
|
||||
bitmap.recycle()
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to draw widget", e)
|
||||
} finally {
|
||||
bitmaps.forEach { it.recycle() }
|
||||
bitmaps.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
||||
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, "widget://$widgetId".toUri(), context, HomeWidgetProvider::class.java)
|
||||
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, Uri.parse("widget://$widgetId"), context, HomeWidgetProvider::class.java)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
|
@ -278,7 +180,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
|||
|
||||
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
||||
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
|
||||
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, "widget://$widgetId".toUri(), context, MainActivity::class.java)
|
||||
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
|
||||
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
|
@ -297,7 +199,6 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
|||
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
|
||||
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
|
||||
private const val WIDGET_DRAW_CHANNEL = "deckers.thibault/aves/widget_draw"
|
||||
private const val DRAW_RETRY_MAX = 5
|
||||
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
private var imageByteFetchJob: Job? = null
|
||||
|
|
|
@ -1,63 +1,29 @@
|
|||
package deckers.thibault.aves
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.KeyguardManager
|
||||
import android.app.SearchManager
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.TransactionTooLargeException
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||
import deckers.thibault.aves.channel.calls.AnalysisHandler
|
||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||
import deckers.thibault.aves.channel.calls.AppProfileHandler
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.DebugHandler
|
||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
|
||||
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
||||
import deckers.thibault.aves.channel.calls.GlobalSearchHandler
|
||||
import deckers.thibault.aves.channel.calls.HomeWidgetHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaEditHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaSessionHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||
import deckers.thibault.aves.channel.calls.MetadataEditHandler
|
||||
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||
import deckers.thibault.aves.channel.calls.SecurityHandler
|
||||
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||
import deckers.thibault.aves.channel.calls.WallpaperHandler
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
|
||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||
import deckers.thibault.aves.channel.streams.ActivityResultStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.AnalysisStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.ErrorStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.ImageOpStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.IntentStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaStoreChangeStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.channel.streams.*
|
||||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.anyCauseIs
|
||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
@ -71,7 +37,6 @@ import kotlinx.coroutines.launch
|
|||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// `FlutterFragmentActivity` because of local auth plugin
|
||||
open class MainActivity : FlutterFragmentActivity() {
|
||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
|
@ -86,6 +51,13 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
|
||||
if (isSoftwareRenderingRequired()) {
|
||||
intent.enableSoftwareRendering()
|
||||
// running the app from Android Studio automatically adds to the intent the `start-paused` flag
|
||||
// so the IDE can connect to the app, but launching on KitKat emulators fails because of a timeout
|
||||
intent.removeExtra("start-paused")
|
||||
}
|
||||
|
||||
intent.extras?.takeUnless { it.isEmpty }?.let {
|
||||
Log.i(LOG_TAG, "onCreate intent extras=$it")
|
||||
}
|
||||
|
@ -144,9 +116,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
MethodChannel(messenger, MediaEditHandler.CHANNEL).setMethodCallHandler(MediaEditHandler(this))
|
||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||
// - need Activity
|
||||
MethodChannel(messenger, AppProfileHandler.CHANNEL).setMethodCallHandler(AppProfileHandler(this))
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
|
@ -179,7 +149,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
intentDataMap.clear()
|
||||
}
|
||||
|
||||
"submitPickedItems" -> safe(call, result, ::submitPickedItems)
|
||||
"submitPickedItems" -> submitPickedItems(call)
|
||||
"submitPickedCollectionFilters" -> submitPickedCollectionFilters(call)
|
||||
}
|
||||
}
|
||||
|
@ -197,9 +167,11 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
// as of Flutter v3.0.1, the window `viewInsets` and `viewPadding`
|
||||
// are incorrect on startup in some environments (e.g. API 29 emulator),
|
||||
// so we manually request to apply the insets to update the window metrics
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
window.decorView.requestApplyInsets()
|
||||
}, 100)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
window.decorView.requestApplyInsets()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -235,7 +207,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
|
@ -247,7 +218,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||
|
||||
PICK_COLLECTION_FILTERS_REQUEST -> onCollectionFiltersPickResult(resultCode, data)
|
||||
EDIT_REQUEST -> onEditResult(resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -256,14 +226,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
pendingCollectionFilterPickHandler?.let { it(filters) }
|
||||
}
|
||||
|
||||
private fun onEditResult(resultCode: Int, intent: Intent?) {
|
||||
val fields: FieldMap? = if (resultCode == RESULT_OK) hashMapOf(
|
||||
"uri" to intent?.data?.toString(),
|
||||
"mimeType" to intent?.type,
|
||||
) else null
|
||||
pendingEditIntentHandler?.let { it(fields) }
|
||||
}
|
||||
|
||||
private fun onDocumentTreeAccessResult(requestCode: Int, resultCode: Int, intent: Intent?) {
|
||||
val treeUri = intent?.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
|
@ -295,65 +257,35 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
open fun extractIntentData(intent: Intent?): FieldMap {
|
||||
open fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (val action = intent?.action) {
|
||||
Intent.ACTION_MAIN -> {
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_PAGE to intent.getStringExtra(EXTRA_KEY_PAGE),
|
||||
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
|
||||
INTENT_DATA_KEY_EXPLORER_PATH to intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH),
|
||||
)
|
||||
if (intent.getBooleanExtra(EXTRA_KEY_SAFE_MODE, false)) {
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_SAFE_MODE to true,
|
||||
)
|
||||
}
|
||||
intent.getStringExtra(EXTRA_KEY_PAGE)?.let { page ->
|
||||
val filters = extractFiltersFromIntent(intent)
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_PAGE to page,
|
||||
INTENT_DATA_KEY_FILTERS to filters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_VIEW,
|
||||
Intent.ACTION_SEND,
|
||||
MediaStore.ACTION_REVIEW,
|
||||
MediaStore.ACTION_REVIEW_SECURE,
|
||||
"com.android.camera.action.REVIEW",
|
||||
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||
if (uri.scheme == "geo") {
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW_GEO,
|
||||
INTENT_DATA_KEY_URI to uri.toString(),
|
||||
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
|
||||
)
|
||||
}
|
||||
|
||||
// MIME type is optional
|
||||
val type = intent.type ?: intent.resolveType(this)
|
||||
val fields = hashMapOf<String, Any?>(
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||
INTENT_DATA_KEY_MIME_TYPE to type,
|
||||
INTENT_DATA_KEY_URI to uri.toString(),
|
||||
)
|
||||
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val isLocked = keyguardManager.isKeyguardLocked
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(isLocked)
|
||||
}
|
||||
if (isLocked) {
|
||||
// device is locked, so access to content is limited to intent URI by default
|
||||
fields[INTENT_DATA_KEY_SECURE_URIS] = listOf(uri.toString())
|
||||
}
|
||||
|
||||
if (action == MediaStore.ACTION_REVIEW_SECURE) {
|
||||
val uris = ArrayList<String>()
|
||||
intent.clipData?.let { clipData ->
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
clipData.getItemAt(i).uri?.let { uris.add(it.toString()) }
|
||||
}
|
||||
}
|
||||
if (uris.isNotEmpty()) {
|
||||
fields[INTENT_DATA_KEY_SECURE_URIS] = uris
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && intent.hasExtra(MediaStore.EXTRA_BRIGHTNESS)) {
|
||||
fields[INTENT_DATA_KEY_BRIGHTNESS] = intent.getFloatExtra(MediaStore.EXTRA_BRIGHTNESS, 0f)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -373,8 +305,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
|
||||
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
||||
INTENT_DATA_KEY_MIME_TYPES to intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList(),
|
||||
INTENT_DATA_KEY_ALLOW_MULTIPLE to intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false),
|
||||
INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -434,54 +365,28 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
return null
|
||||
}
|
||||
|
||||
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
||||
private fun submitPickedItems(call: MethodCall) {
|
||||
val pickedUris = call.argument<List<String>>("uris")
|
||||
if (pickedUris.isNullOrEmpty()) {
|
||||
setResult(RESULT_CANCELED)
|
||||
// move code triggering `Binder` call off the main thread
|
||||
defaultScope.launch { finish() }
|
||||
return
|
||||
}
|
||||
|
||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
|
||||
val intent = Intent().apply {
|
||||
val firstUri = toUri(pickedUris.first())
|
||||
if (pickedUris.size == 1) {
|
||||
data = firstUri
|
||||
} else {
|
||||
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
|
||||
pickedUris.drop(1).forEach {
|
||||
addItem(ClipData.Item(toUri(it)))
|
||||
if (!pickedUris.isNullOrEmpty()) {
|
||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
||||
val intent = Intent().apply {
|
||||
val firstUri = toUri(pickedUris.first())
|
||||
if (pickedUris.size == 1) {
|
||||
data = firstUri
|
||||
} else {
|
||||
clipData = ClipData.newUri(contentResolver, null, firstUri).apply {
|
||||
pickedUris.drop(1).forEach {
|
||||
addItem(ClipData.Item(toUri(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
// move code triggering `Binder` call off the main thread
|
||||
defaultScope.launch {
|
||||
submitPickedItemsIntent(intent, result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitPickedItemsIntent(intent: Intent, result: MethodChannel.Result) {
|
||||
try {
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
if (e is SecurityException && intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
|
||||
// in some environments, providing the write flag yields a `SecurityException`:
|
||||
// "UID XXXX does not have permission to content://XXXX"
|
||||
// so we retry without it
|
||||
Log.i(LOG_TAG, "retry submitting picked items without FLAG_GRANT_WRITE_URI_PERMISSION")
|
||||
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
|
||||
submitPickedItemsIntent(intent, result)
|
||||
} else if (e.anyCauseIs<TransactionTooLargeException>()) {
|
||||
result.error("submitPickedItems-large", "transaction too large with ${intent.clipData?.itemCount} URIs", e)
|
||||
} else {
|
||||
result.error("submitPickedItems-exception", "failed to pick ${intent.clipData?.itemCount} URIs", e)
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun submitPickedCollectionFilters(call: MethodCall) {
|
||||
|
@ -510,16 +415,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, SEARCH_PAGE_ROUTE_NAME)
|
||||
)
|
||||
.build()
|
||||
|
||||
val map = ShortcutInfoCompat.Builder(this, "map")
|
||||
.setShortLabel(getString(R.string.map_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, MAP_PAGE_ROUTE_NAME)
|
||||
.putExtra(EXTRA_KEY_PAGE, "/search")
|
||||
)
|
||||
.build()
|
||||
|
||||
|
@ -528,12 +424,21 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, COLLECTION_PAGE_ROUTE_NAME)
|
||||
.putExtra(EXTRA_KEY_PAGE, "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||
)
|
||||
.build()
|
||||
|
||||
val shortcutInfoList = listOf(videos, search, map)
|
||||
val safeMode = ShortcutInfoCompat.Builder(this, "safeMode")
|
||||
.setShortLabel(getString(R.string.safe_mode_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_safe_mode else R.drawable.ic_shortcut_safe_mode))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_SAFE_MODE, true)
|
||||
)
|
||||
.build()
|
||||
|
||||
val shortcutInfoList = listOf(videos, search, safeMode)
|
||||
ShortcutManagerCompat.setDynamicShortcuts(this, shortcutInfoList)
|
||||
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
|
||||
}
|
||||
|
@ -553,7 +458,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
|
||||
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
|
||||
const val PICK_COLLECTION_FILTERS_REQUEST = 7
|
||||
const val EDIT_REQUEST = 8
|
||||
|
||||
const val INTENT_ACTION_EDIT = "edit"
|
||||
const val INTENT_ACTION_PICK_ITEMS = "pick_items"
|
||||
|
@ -563,36 +467,25 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
const val INTENT_ACTION_SEARCH = "search"
|
||||
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
||||
const val INTENT_ACTION_VIEW = "view"
|
||||
const val INTENT_ACTION_VIEW_GEO = "view_geo"
|
||||
const val INTENT_ACTION_WIDGET_OPEN = "widget_open"
|
||||
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
|
||||
|
||||
const val INTENT_DATA_KEY_ACTION = "action"
|
||||
const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple"
|
||||
const val INTENT_DATA_KEY_BRIGHTNESS = "brightness"
|
||||
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
|
||||
const val INTENT_DATA_KEY_FILTERS = "filters"
|
||||
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
||||
const val INTENT_DATA_KEY_MIME_TYPES = "mimeTypes"
|
||||
const val INTENT_DATA_KEY_PAGE = "page"
|
||||
const val INTENT_DATA_KEY_QUERY = "query"
|
||||
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
|
||||
const val INTENT_DATA_KEY_SAFE_MODE = "safeMode"
|
||||
const val INTENT_DATA_KEY_URI = "uri"
|
||||
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
|
||||
|
||||
const val EXTRA_KEY_PAGE = "page"
|
||||
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
|
||||
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
|
||||
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
|
||||
const val EXTRA_KEY_SAFE_MODE = "safeMode"
|
||||
const val EXTRA_KEY_WIDGET_ID = "widgetId"
|
||||
|
||||
// dart page routes
|
||||
const val COLLECTION_PAGE_ROUTE_NAME = "/collection"
|
||||
const val ENTRY_VIEWER_PAGE_ROUTE_NAME = "/viewer"
|
||||
const val EXPLORER_PAGE_ROUTE_NAME = "/explorer"
|
||||
const val MAP_PAGE_ROUTE_NAME = "/map"
|
||||
const val SEARCH_PAGE_ROUTE_NAME = "/search"
|
||||
|
||||
// request code to pending runnable
|
||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||
|
||||
|
@ -600,8 +493,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
|||
|
||||
var pendingCollectionFilterPickHandler: ((filters: List<String>?) -> Unit)? = null
|
||||
|
||||
var pendingEditIntentHandler: ((fields: FieldMap?) -> Unit)? = null
|
||||
|
||||
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
|
||||
Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
|
||||
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
|
||||
|
|
|
@ -5,19 +5,10 @@ import android.util.Log
|
|||
import android.view.View
|
||||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaSessionHandler
|
||||
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.FlutterInjector
|
||||
|
@ -27,14 +18,12 @@ import io.flutter.embedding.android.FlutterView
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor.DartEntrypoint
|
||||
import io.flutter.embedding.engine.plugins.util.GeneratedPluginRegister
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// for FlutterView-level integration, cf https://docs.flutter.dev/development/add-to-app/android/add-flutter-view
|
||||
class ScreenSaverService : DreamService() {
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
private var flutterView: FlutterView? = null
|
||||
private lateinit var mediaSessionHandler: MediaSessionHandler
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
Log.i(LOG_TAG, "onAttachedToWindow")
|
||||
|
@ -88,7 +77,6 @@ class ScreenSaverService : DreamService() {
|
|||
|
||||
private fun release() {
|
||||
destroyView()
|
||||
mediaSessionHandler.dispose()
|
||||
flutterEngine = null
|
||||
flutterView = null
|
||||
}
|
||||
|
@ -108,19 +96,12 @@ class ScreenSaverService : DreamService() {
|
|||
private fun initChannels() {
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
|
||||
// notification: platform -> dart
|
||||
val mediaCommandStreamHandler = MediaCommandStreamHandler().apply {
|
||||
EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
||||
// dart -> platform -> dart
|
||||
// - need Context
|
||||
mediaSessionHandler = MediaSessionHandler(this, mediaCommandStreamHandler)
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this))
|
||||
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
|
||||
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
package deckers.thibault.aves
|
||||
|
||||
import android.content.Intent
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
||||
class ScreenSaverSettingsActivity : MainActivity() {
|
||||
override fun extractIntentData(intent: Intent?): FieldMap {
|
||||
override fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SCREEN_SAVER_SETTINGS,
|
||||
)
|
||||
|
|
|
@ -16,12 +16,8 @@ import deckers.thibault.aves.utils.FlutterUtils
|
|||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
@ -38,7 +34,7 @@ class SearchSuggestionsProvider : ContentProvider() {
|
|||
val columns = arrayOf(
|
||||
SearchManager.SUGGEST_COLUMN_INTENT_DATA,
|
||||
SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA,
|
||||
SearchManager.SUGGEST_COLUMN_CONTENT_TYPE,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) SearchManager.SUGGEST_COLUMN_CONTENT_TYPE else "mimeType",
|
||||
SearchManager.SUGGEST_COLUMN_TEXT_1,
|
||||
SearchManager.SUGGEST_COLUMN_TEXT_2,
|
||||
SearchManager.SUGGEST_COLUMN_ICON_1,
|
||||
|
|
|
@ -2,55 +2,131 @@ package deckers.thibault.aves
|
|||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler
|
||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class WallpaperActivity : MainActivity() {
|
||||
private var originalIntent: String? = null
|
||||
class WallpaperActivity : FlutterFragmentActivity() {
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
private lateinit var mediaSessionHandler: MediaSessionHandler
|
||||
|
||||
override fun extractIntentData(intent: Intent?): FieldMap {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||
// MIME type is optional
|
||||
val type = intent.type ?: intent.resolveType(this)
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SET_WALLPAPER,
|
||||
INTENT_DATA_KEY_MIME_TYPE to type,
|
||||
INTENT_DATA_KEY_URI to uri.toString(),
|
||||
)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (FlutterUtils.isSoftwareRenderingRequired()) {
|
||||
intent.enableSoftwareRendering()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// if the media URI is not provided we need to pick one first
|
||||
originalIntent = intent.action
|
||||
intent.action = Intent.ACTION_PICK
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
intent.extras?.takeUnless { it.isEmpty }?.let {
|
||||
Log.i(LOG_TAG, "onCreate intent extras=$it")
|
||||
}
|
||||
intentDataMap = extractIntentData(intent)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val messenger = flutterEngine.dartExecutor
|
||||
|
||||
// notification: platform -> dart
|
||||
val mediaCommandStreamHandler = MediaCommandStreamHandler().apply {
|
||||
EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
||||
// dart -> platform -> dart
|
||||
// - need Context
|
||||
mediaSessionHandler = MediaSessionHandler(this, mediaCommandStreamHandler)
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this))
|
||||
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
|
||||
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
// - need ContextWrapper
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||
// - need Activity
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
// - need Context
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
|
||||
// intent handling
|
||||
// detail fetch: dart -> platform
|
||||
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
Log.i(LOG_TAG, "onStart")
|
||||
super.onStart()
|
||||
|
||||
// as of Flutter v3.0.1, the window `viewInsets` and `viewPadding`
|
||||
// are incorrect on startup in some environments (e.g. API 29 emulator),
|
||||
// so we manually request to apply the insets to update the window metrics
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
window.decorView.requestApplyInsets()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSessionHandler.dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getIntentData" -> {
|
||||
result.success(intentDataMap)
|
||||
intentDataMap.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||
// MIME type is optional
|
||||
val type = intent.type ?: intent.resolveType(this)
|
||||
return hashMapOf(
|
||||
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SET_WALLPAPER,
|
||||
MainActivity.INTENT_DATA_KEY_MIME_TYPE to type,
|
||||
MainActivity.INTENT_DATA_KEY_URI to uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_RUN -> {
|
||||
// flutter run
|
||||
}
|
||||
else -> {
|
||||
Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
|
||||
}
|
||||
}
|
||||
|
||||
return super.extractIntentData(intent)
|
||||
return HashMap()
|
||||
}
|
||||
|
||||
override fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (originalIntent != null) {
|
||||
val pickedUris = call.argument<List<String>>("uris")
|
||||
if (!pickedUris.isNullOrEmpty()) {
|
||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
|
||||
onNewIntent(Intent().apply {
|
||||
action = originalIntent
|
||||
data = toUri(pickedUris.first())
|
||||
})
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
super.submitPickedItems(call, result)
|
||||
}
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<WallpaperActivity>()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,28 +21,27 @@ class AvesByteSendingMethodCodec private constructor() : MethodCodec {
|
|||
return STANDARD.encodeMethodCall(methodCall)
|
||||
}
|
||||
|
||||
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
||||
return STANDARD.encodeErrorEnvelope(errorCode, errorMessage, errorDetails)
|
||||
}
|
||||
|
||||
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
||||
return STANDARD.encodeErrorEnvelopeWithStacktrace(errorCode, errorMessage, errorDetails, errorStacktrace)
|
||||
}
|
||||
|
||||
// `StandardMethodCodec` writes the result to a `ByteArrayOutputStream`, then writes the stream to a `ByteBuffer`.
|
||||
// Here we only handle `ByteArray` results, but we avoid the intermediate stream.
|
||||
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
||||
if (result is ByteArray) {
|
||||
return ByteBuffer.allocateDirect(1 + result.size).apply {
|
||||
// following `StandardMethodCodec`:
|
||||
// First byte is zero in success case, and non-zero otherwise.
|
||||
val size = result.size
|
||||
return ByteBuffer.allocateDirect(4 + size).apply {
|
||||
put(0)
|
||||
put(result)
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
|
||||
return encodeErrorEnvelope("invalid-result-type", "Called success with a result which is not a `ByteArray`, type=${result?.javaClass}", null)
|
||||
return ByteBuffer.allocateDirect(0)
|
||||
}
|
||||
|
||||
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
|
||||
Log.e(LOG_TAG, "encodeErrorEnvelope failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails")
|
||||
return ByteBuffer.allocateDirect(0)
|
||||
}
|
||||
|
||||
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
|
||||
Log.e(LOG_TAG, "encodeErrorEnvelopeWithStacktrace failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, errorStacktrace=$errorStacktrace")
|
||||
return ByteBuffer.allocateDirect(0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -6,7 +6,6 @@ import android.content.res.Configuration
|
|||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -18,7 +17,6 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||
"getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout)
|
||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
||||
|
@ -36,10 +34,6 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
|||
result.success(removed)
|
||||
}
|
||||
|
||||
private fun getLongPressTimeout(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(ViewConfiguration.getLongPressTimeout())
|
||||
}
|
||||
|
||||
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.app.ComponentActivity
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import deckers.thibault.aves.AnalysisWorker
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -19,7 +18,8 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AnalysisHandler(private val activity: FlutterFragmentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
||||
|
||||
class AnalysisHandler(private val activity: ComponentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
@ -37,10 +37,10 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
|||
return
|
||||
}
|
||||
|
||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
preferences.edit {
|
||||
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
|
||||
}
|
||||
activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle)
|
||||
.apply()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
|
@ -51,36 +51,35 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
|||
return
|
||||
}
|
||||
|
||||
val activityManager: ActivityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val runningAppProcesses = activityManager.runningAppProcesses
|
||||
if (runningAppProcesses != null) {
|
||||
val importance = runningAppProcesses[0].importance
|
||||
if (importance < ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
|
||||
// the app is in the background
|
||||
result.error("startAnalysis-background", "app is in the background (process importance=$importance)", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// can be null or empty
|
||||
val allEntryIds = call.argument<List<Int>>("entryIds")
|
||||
val progressTotal = allEntryIds?.size ?: 0
|
||||
var progressOffset = 0
|
||||
|
||||
// work `Data` cannot occupy more than 10240 bytes when serialized
|
||||
// so we save the possibly long list of entry IDs to shared preferences
|
||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
preferences.edit {
|
||||
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
|
||||
// so we split it when we have a long list of entry IDs
|
||||
val chunked = allEntryIds?.chunked(WORK_DATA_CHUNK_SIZE) ?: listOf(null)
|
||||
|
||||
fun buildRequest(entryIds: List<Int>?, progressOffset: Int): OneTimeWorkRequest {
|
||||
val workData = workDataOf(
|
||||
AnalysisWorker.KEY_ENTRY_IDS to entryIds?.toIntArray(),
|
||||
AnalysisWorker.KEY_FORCE to force,
|
||||
AnalysisWorker.KEY_PROGRESS_TOTAL to progressTotal,
|
||||
AnalysisWorker.KEY_PROGRESS_OFFSET to progressOffset,
|
||||
)
|
||||
return OneTimeWorkRequestBuilder<AnalysisWorker>().apply { setInputData(workData) }.build()
|
||||
}
|
||||
|
||||
val workData = workDataOf(
|
||||
AnalysisWorker.KEY_FORCE to force,
|
||||
)
|
||||
|
||||
WorkManager.getInstance(activity).beginUniqueWork(
|
||||
var work = WorkManager.getInstance(activity).beginUniqueWork(
|
||||
ANALYSIS_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<AnalysisWorker>().apply { setInputData(workData) }.build(),
|
||||
).enqueue()
|
||||
buildRequest(chunked.first(), progressOffset),
|
||||
)
|
||||
chunked.drop(1).forEach { entryIds ->
|
||||
progressOffset += WORK_DATA_CHUNK_SIZE
|
||||
work = work.then(buildRequest(entryIds, progressOffset))
|
||||
}
|
||||
work.enqueue()
|
||||
|
||||
attachToActivity()
|
||||
result.success(null)
|
||||
|
@ -106,5 +105,6 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
|||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/analysis"
|
||||
private const val ANALYSIS_WORK_NAME = "analysis_work"
|
||||
private const val WORK_DATA_CHUNK_SIZE = 1000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
|
@ -19,27 +15,21 @@ import androidx.core.content.FileProvider
|
|||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.MainActivity.Companion.COLLECTION_PAGE_ROUTE_NAME
|
||||
import deckers.thibault.aves.MainActivity.Companion.ENTRY_VIEWER_PAGE_ROUTE_NAME
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXPLORER_PAGE_ROUTE_NAME
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
|
||||
import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME
|
||||
import deckers.thibault.aves.R
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.anyCauseIs
|
||||
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
||||
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -51,8 +41,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||
|
@ -63,6 +52,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
|
||||
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
|
||||
"edit" -> safe(call, result, ::edit)
|
||||
"open" -> safe(call, result, ::open)
|
||||
"openMap" -> safe(call, result, ::openMap)
|
||||
"setAs" -> safe(call, result, ::setAs)
|
||||
|
@ -153,7 +143,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
||||
val density = context.resources.displayMetrics.density
|
||||
val size = (sizeDip * density).roundToInt()
|
||||
var bytes: ByteArray? = null
|
||||
var data: ByteArray? = null
|
||||
try {
|
||||
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
|
||||
if (iconResourceId != Resources.ID_NULL) {
|
||||
|
@ -174,9 +164,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
try {
|
||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||
// do not recycle bitmaps fetched from `ContentResolver` as their lifecycle is unknown
|
||||
val recycle = false
|
||||
bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
||||
data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||
}
|
||||
|
@ -186,15 +174,15 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
||||
return
|
||||
}
|
||||
if (bytes != null) {
|
||||
result.success(bytes)
|
||||
if (data != null) {
|
||||
result.success(data)
|
||||
} else {
|
||||
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val label = call.argument<String>("label")
|
||||
if (uri == null) {
|
||||
result.error("copyToClipboard-args", "missing arguments", null)
|
||||
|
@ -219,9 +207,25 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun edit(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
if (uri == null) {
|
||||
result.error("edit-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_EDIT)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
.setDataAndType(getShareableUri(context, uri), mimeType)
|
||||
val started = safeStartActivity(intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<String>("title")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val forceChooser = call.argument<Boolean>("forceChooser")
|
||||
if (uri == null || forceChooser == null) {
|
||||
|
@ -238,7 +242,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
||||
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
|
||||
if (geoUri == null) {
|
||||
result.error("openMap-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -252,7 +256,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<String>("title")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
if (uri == null) {
|
||||
result.error("setAs-args", "missing arguments", null)
|
||||
|
@ -275,7 +279,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, it.toUri()) })
|
||||
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, Uri.parse(it)) })
|
||||
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
||||
|
||||
// simplify share intent for a single item, as some apps can handle one item but not more
|
||||
|
@ -310,7 +314,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
val started = safeStartActivityChooser(title, intent)
|
||||
result.success(started)
|
||||
} catch (e: Exception) {
|
||||
if (e.anyCauseIs<TransactionTooLargeException>()) {
|
||||
if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
|
||||
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
|
||||
} else {
|
||||
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
|
||||
|
@ -361,17 +365,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
// shortcuts
|
||||
|
||||
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
||||
// common arguments
|
||||
val label = call.argument<String>("label")
|
||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||
val route = call.argument<String>("route")
|
||||
// route dependent arguments
|
||||
val filters = call.argument<List<String>>("filters")
|
||||
val explorerPath = call.argument<String>("path")
|
||||
val viewUri = call.argument<String>("viewUri")?.toUri()
|
||||
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||
|
||||
if (label == null || route == null) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (label == null || (filters == null && uri == null)) {
|
||||
result.error("pin-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
@ -395,60 +393,19 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
val resId = when (route) {
|
||||
MAP_PAGE_ROUTE_NAME -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map
|
||||
else -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection
|
||||
}
|
||||
icon = IconCompat.createWithResource(context, resId)
|
||||
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
||||
}
|
||||
|
||||
val intent: Intent = when (route) {
|
||||
COLLECTION_PAGE_ROUTE_NAME -> {
|
||||
if (filters == null) {
|
||||
result.error("pin-filters", "collection shortcut requires filters", null)
|
||||
return
|
||||
}
|
||||
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, route)
|
||||
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
}
|
||||
|
||||
ENTRY_VIEWER_PAGE_ROUTE_NAME -> {
|
||||
if (viewUri == null) {
|
||||
result.error("pin-viewUri", "viewer shortcut requires URI", null)
|
||||
return
|
||||
}
|
||||
Intent(Intent.ACTION_VIEW, viewUri, context, MainActivity::class.java)
|
||||
}
|
||||
|
||||
EXPLORER_PAGE_ROUTE_NAME -> {
|
||||
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, route)
|
||||
.putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
|
||||
}
|
||||
|
||||
MAP_PAGE_ROUTE_NAME -> {
|
||||
if (geoUri == null) {
|
||||
result.error("pin-geoUri", "map shortcut requires URI", null)
|
||||
return
|
||||
}
|
||||
Intent(Intent.ACTION_VIEW, geoUri, context, MainActivity::class.java).apply {
|
||||
putExtra(EXTRA_KEY_PAGE, route)
|
||||
// filters are optional
|
||||
filters?.let {
|
||||
putExtra(EXTRA_KEY_FILTERS_ARRAY, it.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
putExtra(EXTRA_KEY_FILTERS_STRING, it.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val intent = when {
|
||||
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
||||
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra(EXTRA_KEY_PAGE, "/collection")
|
||||
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
else -> {
|
||||
result.error("pin-route", "unsupported shortcut route=$route", null)
|
||||
result.error("pin-intent", "failed to build intent", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -477,7 +434,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
FileProvider.getUriForFile(context, authority, File(path))
|
||||
}
|
||||
}
|
||||
|
||||
else -> uri
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.CrossProfileApps
|
||||
import android.os.Build
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
||||
class AppProfileHandler(private val activity: Activity) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"canInteractAcrossProfiles" -> safe(call, result, ::canInteractAcrossProfiles)
|
||||
"canRequestInteractAcrossProfiles" -> safe(call, result, ::canRequestInteractAcrossProfiles)
|
||||
"requestInteractAcrossProfiles" -> safe(call, result, ::requestInteractAcrossProfiles)
|
||||
"switchProfile" -> safe(call, result, ::switchProfile)
|
||||
"getProfileSwitchingLabel" -> safe(call, result, ::getProfileSwitchingLabel)
|
||||
"getTargetUserProfiles" -> safe(call, result, ::getTargetUserProfiles)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
result.success(crossProfileApps.canInteractAcrossProfiles())
|
||||
}
|
||||
|
||||
private fun canRequestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
result.success(crossProfileApps.canRequestInteractAcrossProfiles())
|
||||
}
|
||||
|
||||
private fun requestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
val intent = crossProfileApps.createRequestInteractAcrossProfilesIntent()
|
||||
val started = activity.startActivity(intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun switchProfile(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
val userHandles = crossProfileApps.targetUserProfiles
|
||||
crossProfileApps.startMainActivity(activity.componentName, userHandles.first())
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun getProfileSwitchingLabel(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
val userHandles = crossProfileApps.targetUserProfiles
|
||||
val label = if (userHandles.isEmpty()) "" else crossProfileApps.getProfileSwitchingLabel(userHandles.first())
|
||||
|
||||
result.success(label)
|
||||
}
|
||||
|
||||
private fun getTargetUserProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
||||
val userProfiles = crossProfileApps.targetUserProfiles.map { it.toString() }.toList()
|
||||
result.success(userProfiles)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/app_profile"
|
||||
}
|
||||
}
|
|
@ -12,15 +12,11 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -44,7 +40,6 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|||
import org.mp4parser.IsoFile
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
@ -80,9 +75,15 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
"obbDir" to context.obbDir,
|
||||
"externalCacheDir" to context.externalCacheDir,
|
||||
"externalFilesDir" to context.getExternalFilesDir(null),
|
||||
"codeCacheDir" to context.codeCacheDir,
|
||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
putAll(
|
||||
hashMapOf(
|
||||
"codeCacheDir" to context.codeCacheDir,
|
||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
put("dataDir", context.dataDir)
|
||||
}
|
||||
|
@ -103,6 +104,8 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getCodecs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val codecs = ArrayList<FieldMap>()
|
||||
|
||||
fun getFields(info: MediaCodecInfo): FieldMap {
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"name" to info.name,
|
||||
|
@ -119,7 +122,18 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
return fields
|
||||
}
|
||||
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.map(::getFields).toList()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
codecs.addAll(MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.map(::getFields))
|
||||
} else {
|
||||
@Suppress("deprecation")
|
||||
val count = MediaCodecList.getCodecCount()
|
||||
for (i in 0 until count) {
|
||||
@Suppress("deprecation")
|
||||
val info = MediaCodecList.getCodecInfoAt(i)
|
||||
codecs.add(getFields(info))
|
||||
}
|
||||
}
|
||||
|
||||
result.success(codecs)
|
||||
}
|
||||
|
||||
|
@ -128,7 +142,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -157,7 +171,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -213,7 +227,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
||||
|
@ -240,7 +254,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -265,7 +279,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
||||
|
@ -276,7 +290,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
|
||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
|
||||
|
@ -309,14 +323,14 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getMp4ParserDump-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
if (mimeType == MimeTypes.MP4 || MimeTypes.isHeic(mimeType)) {
|
||||
if (mimeType == MimeTypes.MP4) {
|
||||
try {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
|
@ -339,7 +353,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getPixyMetadata-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -360,7 +374,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getTiffStructure-args", "missing arguments", null)
|
||||
return
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.LocaleConfig
|
||||
import android.app.LocaleManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import android.location.Geocoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.LocaleList
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -25,7 +18,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.*
|
||||
|
||||
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
@ -34,14 +27,11 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
||||
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
|
||||
"getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis)
|
||||
"getLocales" -> safe(call, result, ::getLocales)
|
||||
"setLocaleConfig" -> safe(call, result, ::setLocaleConfig)
|
||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||
"isLocked" -> safe(call, result, ::isLocked)
|
||||
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
||||
"requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission)
|
||||
"getAvailableHeapSize" -> safe(call, result, ::getAvailableHeapSize)
|
||||
"requestGarbageCollection" -> safe(call, result, ::requestGarbageCollection)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -54,32 +44,36 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
val sdkInt = Build.VERSION.SDK_INT
|
||||
result.success(
|
||||
hashMapOf(
|
||||
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
||||
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
|
||||
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
|
||||
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
|
||||
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
|
||||
"canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||
"hasGeocoder" to Geocoder.isPresent(),
|
||||
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
||||
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
|
||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
||||
"supportPictureInPicture" to supportPictureInPicture(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun supportPictureInPicture(): Boolean {
|
||||
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
|
||||
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
private fun getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().rawOffset)
|
||||
}
|
||||
|
||||
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
fun toMap(locale: Locale): FieldMap = hashMapOf(
|
||||
"language" to locale.language,
|
||||
"country" to locale.country,
|
||||
"script" to locale.script,
|
||||
)
|
||||
fun toMap(locale: Locale): FieldMap {
|
||||
val fields: HashMap<String, Any?> = hashMapOf(
|
||||
"language" to locale.language,
|
||||
"country" to locale.country,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
fields["script"] = locale.script
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
val locales = ArrayList<FieldMap>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
|
@ -95,22 +89,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(locales)
|
||||
}
|
||||
|
||||
private fun setLocaleConfig(call: MethodCall, result: MethodChannel.Result) {
|
||||
val locales = call.argument<List<String>>("locales")
|
||||
if (locales.isNullOrEmpty()) {
|
||||
result.error("setLocaleConfig-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
@SuppressLint("WrongConstant")
|
||||
val lm = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager
|
||||
lm?.overrideLocaleConfig = LocaleConfig(LocaleList.forLanguageTags(locales.joinToString(",")))
|
||||
}
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
||||
|
@ -122,14 +100,12 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(Build.VERSION.SDK_INT)
|
||||
}
|
||||
|
||||
private fun isLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
|
||||
val isLocked = keyguardManager.isKeyguardLocked
|
||||
result.success(isLocked)
|
||||
}
|
||||
|
||||
private fun isSystemFilePickerEnabled(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val enabled = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null
|
||||
val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
result.success(enabled)
|
||||
}
|
||||
|
||||
|
@ -139,20 +115,11 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, "package:${context.packageName}".toUri())
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
|
||||
context.startActivity(intent)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun getAvailableHeapSize(@Suppress("unused_parameter") methodCall: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(MemoryUtils.getAvailableHeapSize())
|
||||
}
|
||||
|
||||
private fun requestGarbageCollection(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
Runtime.getRuntime().gc()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/device"
|
||||
}
|
||||
|
|
|
@ -1,36 +1,31 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.XMP.doesPropPathExist
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
|
||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ContentImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -45,9 +40,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getExifThumbnails" -> ioScope.launch { safe(call, result, ::getExifThumbnails) }
|
||||
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
||||
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
||||
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
|
||||
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
||||
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
|
||||
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||
|
@ -56,9 +50,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getExifThumbnails-args", "missing arguments", null)
|
||||
|
@ -73,9 +67,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
// do not recycle bitmaps fetched from `ExifInterface` as their lifecycle is unknown
|
||||
val recycle = false
|
||||
BitmapUtils.getRawBytes(it, recycle = recycle)?.let { bytes -> thumbnails.add(bytes) }
|
||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +81,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val displayName = call.argument<String>("displayName")
|
||||
val dataUri = call.argument<String>("dataUri")
|
||||
|
@ -103,12 +95,19 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
// data can be large and stored in "Extended XMP",
|
||||
// which is returned as a second XMP directory
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
container = xmpDirs.firstNotNullOfOrNull { GoogleXMP.getDeviceContainer(it.xmpMeta) }
|
||||
container = xmpDirs.firstNotNullOfOrNull {
|
||||
val xmpMeta = it.xmpMeta
|
||||
if (xmpMeta.doesPropPathExist(listOf(XMP.GDEVICE_CONTAINER_PROP_NAME, XMP.GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
||||
GoogleDeviceContainer().apply { findItems(xmpMeta) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
result.error("extractGoogleDeviceItem-xmp", "failed to read XMP directory for uri=$uri dataUri=$dataUri", e.message)
|
||||
return
|
||||
|
@ -142,43 +141,9 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.error("extractGoogleDeviceItem-empty", "failed to extract item from Google Device XMP at uri=$uri dataUri=$dataUri", null)
|
||||
}
|
||||
|
||||
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val displayName = call.argument<String>("displayName")
|
||||
val id = call.argument<Int>("id")
|
||||
if (mimeType == null || uri == null || sizeBytes == null || id == null) {
|
||||
result.error("extractJpegMpfItem-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val pageIndex = id - 1
|
||||
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes)
|
||||
if (mpEntries != null && pageIndex < mpEntries.size) {
|
||||
val mpEntry = mpEntries[pageIndex]
|
||||
mpEntry.mimeType?.let { embedMimeType ->
|
||||
var dataOffset = mpEntry.dataOffset
|
||||
if (dataOffset > 0) {
|
||||
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
||||
if (baseOffset != null) {
|
||||
dataOffset += baseOffset
|
||||
}
|
||||
}
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(dataOffset)
|
||||
copyEmbeddedBytes(result, embedMimeType, displayName, input, mpEntry.size)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result.error("extractJpegMpfItem-empty", "failed to extract file index=$id from MPF at uri=$uri", null)
|
||||
}
|
||||
|
||||
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val displayName = call.argument<String>("displayName")
|
||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||
|
@ -186,7 +151,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
MultiPage.getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
val imageSizeBytes = sizeBytes - videoSizeBytes
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
||||
|
@ -199,7 +164,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val displayName = call.argument<String>("displayName")
|
||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||
|
@ -207,10 +172,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
MultiPage.getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
|
||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(videoOffset)
|
||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSize)
|
||||
input.skip(videoStartOffset)
|
||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSizeBytes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -219,7 +185,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val displayName = call.argument<String>("displayName")
|
||||
if (uri == null) {
|
||||
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
||||
|
@ -251,7 +217,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val displayName = call.argument<String>("displayName")
|
||||
val dataProp = call.argument<List<Any>>("propPath")
|
||||
|
@ -272,7 +238,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
// data can be large and stored in "Extended XMP",
|
||||
// which is returned as a second XMP directory
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
|
@ -311,7 +277,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
embeddedByteStream: InputStream,
|
||||
embeddedByteLength: Long,
|
||||
) {
|
||||
val extension = extensionFor(mimeType, defaultExtension = null)
|
||||
val extension = extensionFor(mimeType)
|
||||
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
||||
transferFrom(embeddedByteStream, embeddedByteLength)
|
||||
}
|
||||
|
@ -319,7 +285,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
val authority = "${context.applicationContext.packageName}.file_provider"
|
||||
val uri = if (displayName != null) {
|
||||
// add extension to ease type identification when sharing this content
|
||||
val displayNameWithExtension = if (displayName.endsWith(extension, ignoreCase = true)) {
|
||||
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
|
||||
displayName
|
||||
} else {
|
||||
"$displayName$extension"
|
||||
|
@ -329,18 +295,12 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
FileProvider.getUriForFile(context, authority, targetFile)
|
||||
}
|
||||
val resultFields: FieldMap = hashMapOf(
|
||||
EntryFields.URI to uri.toString(),
|
||||
EntryFields.MIME_TYPE to mimeType,
|
||||
"uri" to uri.toString(),
|
||||
"mimeType" to mimeType,
|
||||
)
|
||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||
val provider = getProvider(context, uri)
|
||||
if (provider == null) {
|
||||
result.error("copyEmbeddedBytes-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
ioScope.launch {
|
||||
provider.fetchSingle(context, uri, mimeType, false, object : ImageProvider.ImageOpCallback {
|
||||
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) {
|
||||
resultFields.putAll(fields)
|
||||
result.success(resultFields)
|
||||
|
|
|
@ -31,7 +31,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
|||
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
|
||||
val latitude = call.argument<Number>("latitude")?.toDouble()
|
||||
val longitude = call.argument<Number>("longitude")?.toDouble()
|
||||
val localeLanguageTag = call.argument<String>("localeLanguageTag")
|
||||
val localeString = call.argument<String>("locale")
|
||||
val maxResults = call.argument<Int>("maxResults") ?: 1
|
||||
if (latitude == null || longitude == null) {
|
||||
result.error("getAddress-args", "missing arguments", null)
|
||||
|
@ -43,8 +43,11 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
geocoder = geocoder ?: if (localeLanguageTag != null) {
|
||||
Geocoder(context, Locale.forLanguageTag(localeLanguageTag))
|
||||
geocoder = geocoder ?: if (localeString != null) {
|
||||
val split = localeString.split("_")
|
||||
val language = split[0]
|
||||
val country = if (split.size > 1) split[1] else ""
|
||||
Geocoder(context, Locale(language, country))
|
||||
} else {
|
||||
Geocoder(context)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import deckers.thibault.aves.SearchSuggestionsProvider
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -29,10 +28,10 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
preferences.edit {
|
||||
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
||||
}
|
||||
context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
||||
.apply()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
@ -44,7 +44,7 @@ class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallH
|
|||
}
|
||||
|
||||
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val desiredName = call.argument<String>("desiredName")
|
||||
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||
val bytes = call.argument<ByteArray>("bytes")
|
||||
|
@ -55,7 +55,7 @@ class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallH
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("captureFrame-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
|
|
|
@ -2,13 +2,12 @@ package deckers.thibault.aves.channel.calls
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import androidx.core.net.toUri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
@ -17,7 +16,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||
|
@ -28,25 +26,25 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getThumbnail" -> ioScope.launch { safe(call, result, ::getThumbnail) }
|
||||
"getRegion" -> ioScope.launch { safe(call, result, ::getRegion) }
|
||||
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
|
||||
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>(EntryFields.URI)
|
||||
val mimeType = call.argument<String>(EntryFields.MIME_TYPE)
|
||||
val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong()
|
||||
val rotationDegrees = call.argument<Int>(EntryFields.ROTATION_DEGREES)
|
||||
val isFlipped = call.argument<Boolean>(EntryFields.IS_FLIPPED)
|
||||
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
|
||||
val rotationDegrees = call.argument<Int>("rotationDegrees")
|
||||
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
||||
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
||||
val pageId = call.argument<Int>("pageId")
|
||||
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
||||
val quality = call.argument<Int>("quality")
|
||||
|
||||
if (uri == null || mimeType == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
|
||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null || quality == null) {
|
||||
result.error("getThumbnail-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
@ -56,7 +54,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
|||
context = context,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
|
||||
dateModifiedSecs = dateModifiedSecs,
|
||||
rotationDegrees = rotationDegrees,
|
||||
isFlipped = isFlipped,
|
||||
width = (widthDip * density).roundToInt(),
|
||||
|
@ -68,8 +66,8 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
|||
).fetch()
|
||||
}
|
||||
|
||||
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val pageId = call.argument<Int>("pageId")
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
|
@ -91,13 +89,11 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
|||
MimeTypes.SVG -> SvgRegionFetcher(context).fetch(
|
||||
uri = uri,
|
||||
sizeBytes = sizeBytes,
|
||||
scale = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
result = result,
|
||||
)
|
||||
|
||||
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
|
||||
uri = uri,
|
||||
page = pageId ?: 0,
|
||||
|
@ -105,7 +101,6 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
|||
regionRect = regionRect,
|
||||
result = result,
|
||||
)
|
||||
|
||||
else -> regionFetcher.fetch(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.net.toUri
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
@ -23,45 +21,36 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
||||
"clearImageDiskCache" -> ioScope.launch { safe(call, result, ::clearImageDiskCache) }
|
||||
"clearImageMemoryCache" -> ioScope.launch { safe(call, result, ::clearImageMemoryCache) }
|
||||
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getEntry-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(context, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("getEntry-provider", "failed to find provider for uri=$uri mimeType=$mimeType", null)
|
||||
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.fetchSingle(context, uri, mimeType, allowUnsized, object : ImageOpCallback {
|
||||
provider.fetchSingle(context, uri, mimeType, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri mimeType=$mimeType", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun clearImageDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
Glide.get(context).clearDiskCache()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun clearImageMemoryCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Glide.get(context).clearMemory()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
|
||||
}
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.*
|
||||
import android.media.AudioManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
|
@ -63,7 +59,7 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
|
|||
}
|
||||
|
||||
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val title = call.argument<String>("title") ?: uri?.toString()
|
||||
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
||||
val stateString = call.argument<String>("state")
|
||||
|
|
|
@ -3,8 +3,6 @@ package deckers.thibault.aves.channel.calls
|
|||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -22,15 +20,13 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
|
||||
"checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) }
|
||||
"getChangedUris" -> ioScope.launch { safe(call, result, ::getChangedUris) }
|
||||
"getGeneration" -> ioScope.launch { safe(call, result, ::getGeneration) }
|
||||
"scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
|
||||
val knownContentIds = call.argument<List<Number?>>("knownContentIds")?.map { it?.toLong() }
|
||||
val knownContentIds = call.argument<List<Int?>>("knownContentIds")
|
||||
if (knownContentIds == null) {
|
||||
result.error("checkObsoleteContentIds-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -39,7 +35,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
|
||||
val knownPathById = call.argument<Map<Number?, String?>>("knownPathById")?.mapKeys { it.key?.toLong() }
|
||||
val knownPathById = call.argument<Map<Int?, String?>>("knownPathById")
|
||||
if (knownPathById == null) {
|
||||
result.error("checkObsoletePaths-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -47,32 +43,6 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
|
||||
}
|
||||
|
||||
private fun getChangedUris(call: MethodCall, result: MethodChannel.Result) {
|
||||
val sinceGeneration = call.argument<Int>("sinceGeneration")
|
||||
if (sinceGeneration == null) {
|
||||
result.error("getChangedUris-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
val uris = MediaStoreImageProvider().getChangedUris(context, sinceGeneration)
|
||||
result.success(uris)
|
||||
}
|
||||
|
||||
private fun getGeneration(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val generation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
MediaStore.getGeneration(context, MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
} catch (e: Exception) {
|
||||
// may yield `IllegalArgumentException: Volume external_primary not found`
|
||||
val volumes = MediaStore.getExternalVolumeNames(context).joinToString(", ")
|
||||
result.error("getGeneration-primary", e.message + " (available volumes are [$volumes])", e)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result.success(generation)
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import androidx.core.net.toUri
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
|
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
|
@ -62,7 +62,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editOrientation-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
|
@ -74,7 +74,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
|
||||
val shiftSeconds = call.argument<Number>("shiftSeconds")?.toLong()
|
||||
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
|
||||
val fields = call.argument<List<String>>("fields")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null || fields == null) {
|
||||
|
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
|
@ -90,14 +90,14 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
val callback = MetadataOpCallback("editDate", entryMap, result)
|
||||
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftSeconds, fields, callback)
|
||||
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, callback)
|
||||
}
|
||||
|
||||
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
|
@ -117,7 +117,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editMetadata-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
|
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
|
@ -142,7 +142,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("removeTrailerVideo-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
|
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
|
@ -168,7 +168,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(contextWrapper, uri)
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("removeTypes-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import com.adobe.internal.xmp.XMPMetaFactory
|
||||
|
@ -23,14 +22,12 @@ import com.drew.metadata.exif.GpsDirectory
|
|||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import com.drew.metadata.gif.GifAnimationDirectory
|
||||
import com.drew.metadata.iptc.IptcDirectory
|
||||
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
|
||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||
import com.drew.metadata.png.PngDirectory
|
||||
import com.drew.metadata.webp.WebpDirectory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.metadata.ExifGeoTiffTags
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
|
||||
|
@ -53,6 +50,15 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper
|
|||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.QuickTimeMetadata
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.doesPropExist
|
||||
import deckers.thibault.aves.metadata.XMP.getPropArrayItemValues
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeInt
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeString
|
||||
import deckers.thibault.aves.metadata.XMP.isMotionPhoto
|
||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_ITXT_DIR_NAME
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.PNG_LAST_MODIFICATION_TIME_FORMAT
|
||||
|
@ -70,22 +76,8 @@ import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational
|
|||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir
|
||||
import deckers.thibault.aves.metadata.metadataextractor.PngActlDirectory
|
||||
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
|
||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory
|
||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||
import deckers.thibault.aves.metadata.xmp.XMP
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.doesPropExist
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getPropArrayItemValues
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeInt
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.hasHdrGainMap
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.isMotionPhoto
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.isPanorama
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue
|
||||
import deckers.thibault.aves.utils.HashUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
|
||||
|
@ -102,15 +94,11 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
||||
import org.mp4parser.tools.Path
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.DecimalFormat
|
||||
import java.text.ParseException
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
@ -128,14 +116,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
"hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) }
|
||||
"getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) }
|
||||
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
|
||||
"getFields" -> ioScope.launch { safe(call, result, ::getFields) }
|
||||
"getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getAllMetadata-args", "missing arguments", null)
|
||||
|
@ -167,11 +155,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
// remove this stat as it is not actual XMP data
|
||||
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||
if (dirMap.isNotEmpty()) {
|
||||
// add schema prefixes for namespace resolution
|
||||
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
||||
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
||||
}
|
||||
// add schema prefixes for namespace resolution
|
||||
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
||||
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
||||
}
|
||||
|
||||
val mp4UuidDirCount = HashMap<String, Int>()
|
||||
|
@ -234,12 +220,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
foundMp4Uuid = metadata.directories.any { it is Mp4UuidBoxDirectory && it.tagCount > 0 }
|
||||
|
||||
val dirByName = metadata.directories.filter {
|
||||
(it.tagCount > 0 || it.errorCount > 0 || it is MpEntryDirectory)
|
||||
(it.tagCount > 0 || it.errorCount > 0)
|
||||
&& it !is FileTypeDirectory
|
||||
&& it !is AviDirectory
|
||||
}.groupBy { dir -> dir.name }
|
||||
|
@ -301,7 +287,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
byGeoTiff[false]?.map { exifTagMapper(it) }?.let { dirMap.putAll(it) }
|
||||
}
|
||||
|
||||
mimeType == MimeTypes.DNG || mimeType == MimeTypes.DNG_ADOBE -> {
|
||||
mimeType == MimeTypes.DNG -> {
|
||||
// split DNG tags in their own directory
|
||||
val dngDirMap = metadataMap[DIR_DNG] ?: HashMap()
|
||||
metadataMap[DIR_DNG] = dngDirMap
|
||||
|
@ -358,10 +344,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
dir is MpEntryDirectory -> {
|
||||
dirMap.putAll(dir.describe())
|
||||
}
|
||||
|
||||
else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
}
|
||||
|
@ -402,21 +384,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
// do not overwrite XMP parsed by metadata-extractor
|
||||
// with raw XMP found by ExifInterface
|
||||
allTags.remove(Metadata.DIR_XMP)
|
||||
} else {
|
||||
val xmpTags = allTags[Metadata.DIR_XMP]
|
||||
if (xmpTags != null) {
|
||||
val xmpRaw = xmpTags[ExifInterface.TAG_XMP]
|
||||
if (xmpRaw != null) {
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
val xmpBytes = xmpRaw.toByteArray(Charsets.UTF_8)
|
||||
SafeXmpReader().extract(xmpBytes, 0, xmpBytes.size, metadata, null)
|
||||
metadata.getFirstDirectoryOfType(XmpDirectory::class.java)?.let { xmpDir ->
|
||||
val dirMap = HashMap<String, String>()
|
||||
processXmp(xmpDir.xmpMeta, dirMap, allowMultiple = true)
|
||||
allTags[Metadata.DIR_XMP] = dirMap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metadataMap.putAll(allTags.mapValues { it.value.toMutableMap() })
|
||||
}
|
||||
|
@ -452,8 +419,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
if (isVideo(mimeType)) {
|
||||
// `metadata-extractor` do not extract custom tags in user data box
|
||||
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { box ->
|
||||
metadataMap[Metadata.DIR_MP4_USER_DATA] = Mp4ParserHelper.extractBoxFields(box)
|
||||
val userDataDir = Mp4ParserHelper.getUserData(context, mimeType, uri)
|
||||
if (userDataDir.isNotEmpty()) {
|
||||
metadataMap[Metadata.DIR_MP4_USER_DATA] = userDataDir
|
||||
}
|
||||
|
||||
// this is used as fallback when the video metadata cannot be found on the Dart side
|
||||
|
@ -472,12 +440,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
||||
// about embedded images as they do not list them as separate tracks
|
||||
// and only identify at most one
|
||||
} else if (isHeic(mimeType)) {
|
||||
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (_, bytes) ->
|
||||
metadataMap[Mp4ParserHelper.SAMSUNG_MAKERNOTE_BOX_TYPE] = hashMapOf(
|
||||
"Size" to bytes.size.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataMap.isNotEmpty()) {
|
||||
|
@ -525,7 +487,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
// - XMP / MicrosoftPhoto:Rating
|
||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val path = call.argument<String>("path")
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
|
@ -535,33 +497,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
||||
|
||||
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
|
||||
|
||||
// fallback to MP4 `loci` box for location
|
||||
if (!metadataMap.contains(KEY_LATITUDE) || !metadataMap.contains(KEY_LONGITUDE)) {
|
||||
try {
|
||||
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { userDataBox ->
|
||||
Path.getPath<LocationInformationBox>(userDataBox, LocationInformationBox.TYPE)?.let { locationBox ->
|
||||
if (!locationBox.isParsed) {
|
||||
locationBox.parseDetails()
|
||||
}
|
||||
metadataMap[KEY_LATITUDE] = locationBox.latitude
|
||||
metadataMap[KEY_LONGITUDE] = locationBox.longitude
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get Location Information box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isHeic(mimeType)) {
|
||||
val flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
if ((flags and MASK_IS_MOTION_PHOTO == 0) && MultiPage.isHeicSefdMotionPhoto(context, uri)) {
|
||||
metadataMap[KEY_FLAGS] = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
|
||||
}
|
||||
}
|
||||
|
||||
// report success even when empty
|
||||
|
@ -614,11 +551,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (xmpMeta.isMotionPhoto()) {
|
||||
flags = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
|
||||
}
|
||||
|
||||
// identification of embedded gain map
|
||||
if (xmpMeta.hasHdrGainMap()) {
|
||||
flags = flags or MASK_IS_HDR
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
|
@ -634,7 +566,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
foundMp4Uuid = metadata.directories.any { it is Mp4UuidBoxDirectory && it.tagCount > 0 }
|
||||
|
||||
|
@ -691,15 +623,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
// JPEG Multi-Picture Format
|
||||
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
|
||||
flags = flags or MASK_IS_MULTIPAGE
|
||||
|
||||
if (hasAppleHdrGainMap(uri, sizeBytes)) {
|
||||
flags = flags or MASK_IS_HDR
|
||||
}
|
||||
}
|
||||
|
||||
// XMP
|
||||
if (!isLargeMp4(mimeType, sizeBytes)) {
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
|
||||
|
@ -719,22 +642,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
|
||||
for (dir in metadata.getDirectoriesOfType(QuickTimeMetadataDirectory::class.java)) {
|
||||
dir.getSafeString(QuickTimeMetadataDirectory.TAG_LOCATION_ISO6709) { locationString ->
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||
val latitude = matcher.group(1)?.toDoubleOrNull()
|
||||
val longitude = matcher.group(2)?.toDoubleOrNull()
|
||||
if (latitude != null && longitude != null) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (mimeType) {
|
||||
MimeTypes.PNG -> {
|
||||
// date fallback to PNG time chunk
|
||||
|
@ -840,31 +747,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap[KEY_FLAGS] = flags
|
||||
}
|
||||
|
||||
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?): Boolean {
|
||||
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
|
||||
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEachIndexed { mpIndex, mpEntry ->
|
||||
var dataOffset = mpEntry.dataOffset
|
||||
if (dataOffset > 0) {
|
||||
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
||||
if (baseOffset != null) {
|
||||
dataOffset += baseOffset
|
||||
}
|
||||
}
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(dataOffset)
|
||||
try {
|
||||
val pageMetadata = Helper.safeRead(input, sizeBytes)
|
||||
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for uri=$uri mpIndex=$mpIndex mpEntry=$mpEntry", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
|
@ -879,7 +761,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
}
|
||||
|
||||
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
|
||||
if (!metadataMap.containsKey(KEY_LATITUDE)) {
|
||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (locationString != null) {
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
|
@ -900,14 +782,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER) {
|
||||
if (it == MediaFormat.COLOR_TRANSFER_ST2084 || it == MediaFormat.COLOR_TRANSFER_HLG) {
|
||||
flags = flags or MASK_IS_HDR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metadataMap[KEY_FLAGS] = flags
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e)
|
||||
|
@ -919,16 +793,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val fields = call.argument<List<String>>("fields")
|
||||
if (mimeType == null || uri == null || fields == null) {
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getOverlayMetadata-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
if (fields.isEmpty() || isVideo(mimeType)) {
|
||||
if (isVideo(mimeType)) {
|
||||
result.success(metadataMap)
|
||||
return
|
||||
}
|
||||
|
@ -950,24 +823,13 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
foundExif = true
|
||||
if (fields.contains(KEY_APERTURE)) {
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
|
||||
}
|
||||
if (fields.contains(KEY_DESCRIPTION)) {
|
||||
getDescriptionByMetadataExtractor(metadata)?.let { metadataMap[KEY_DESCRIPTION] = it }
|
||||
}
|
||||
if (fields.contains(KEY_EXPOSURE_TIME)) {
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||
}
|
||||
if (fields.contains(KEY_FOCAL_LENGTH)) {
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
|
||||
}
|
||||
if (fields.contains(KEY_ISO)) {
|
||||
dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
|
||||
}
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||
dir.getSafeRational(ExifDirectoryBase.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
|
||||
dir.getSafeInt(ExifDirectoryBase.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -984,18 +846,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
if (fields.contains(KEY_APERTURE)) {
|
||||
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
}
|
||||
if (fields.contains(KEY_EXPOSURE_TIME)) {
|
||||
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||
}
|
||||
if (fields.contains(KEY_FOCAL_LENGTH)) {
|
||||
exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
|
||||
}
|
||||
if (fields.contains(KEY_ISO)) {
|
||||
exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it }
|
||||
}
|
||||
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
|
||||
exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
|
||||
exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
|
@ -1007,50 +861,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
// returns description from these fields (by precedence):
|
||||
// - XMP / dc:description
|
||||
// - IPTC / caption-abstract
|
||||
// - Exif / UserComment
|
||||
// - Exif / ImageDescription
|
||||
private fun getDescriptionByMetadataExtractor(metadata: com.drew.metadata.Metadata): String? {
|
||||
var description: String? = null
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
val xmpMeta = dir.xmpMeta
|
||||
try {
|
||||
if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) {
|
||||
xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { description = it }
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory", e)
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
|
||||
dir.getSafeString(IptcDirectory.TAG_CAPTION, acceptBlank = false) { description = it }
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
// user comment field specifies encoding, unlike other string fields
|
||||
if (dir.containsTag(ExifSubIFDDirectory.TAG_USER_COMMENT)) {
|
||||
val string = dir.getDescription(ExifSubIFDDirectory.TAG_USER_COMMENT)
|
||||
if (string.isNotBlank()) {
|
||||
description = string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION, acceptBlank = false) { description = it }
|
||||
}
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
||||
|
@ -1060,7 +873,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
val fields = HashMap<Int, Any?>()
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
if (dir.containsGeoTiffTags()) {
|
||||
|
@ -1091,7 +904,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
||||
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
||||
|
@ -1100,11 +913,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val pages: ArrayList<FieldMap>? = if (isMotionPhoto) {
|
||||
MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes)
|
||||
MultiPage.getMotionPhotoPages(context, uri, mimeType, sizeBytes = sizeBytes)
|
||||
} else {
|
||||
when (mimeType) {
|
||||
MimeTypes.HEIC, MimeTypes.HEIF -> MultiPage.getHeicTracks(context, uri)
|
||||
MimeTypes.JPEG -> MultiPage.getJpegMpfPages(context, uri, sizeBytes)
|
||||
MimeTypes.TIFF -> MultiPage.getTiffPages(context, uri)
|
||||
else -> null
|
||||
}
|
||||
|
@ -1118,7 +930,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getPanoramaInfo-args", "missing arguments", null)
|
||||
|
@ -1131,13 +943,23 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
fun processXmp(xmpMeta: XMPMeta, allowMultiple: Boolean = false) {
|
||||
if (foundXmp && !allowMultiple) return
|
||||
foundXmp = true
|
||||
fields.putAll(GoogleXMP.getPanoramaInfo(xmpMeta))
|
||||
try {
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
|
||||
xmpMeta.getSafeString(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
|
||||
processXmp(it, allowMultiple = true)
|
||||
}
|
||||
|
@ -1163,14 +985,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (fields.isEmpty()) {
|
||||
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
|
||||
} else {
|
||||
fields["projectionType"] = fields["projectionType"] ?: GoogleXMP.GPANO_PROJECTION_TYPE_DEFAULT
|
||||
fields["projectionType"] = fields["projectionType"] ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT
|
||||
result.success(fields)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getIptc-args", "missing arguments", null)
|
||||
return
|
||||
|
@ -1192,11 +1014,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(null)
|
||||
}
|
||||
|
||||
// returns XMP components
|
||||
// returns an empty list if there is no XMP
|
||||
// return XMP components
|
||||
// return an empty list if there is no XMP
|
||||
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getXmp-args", "missing arguments", null)
|
||||
|
@ -1219,7 +1041,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach {
|
||||
processXmp(it, allowMultiple = true)
|
||||
}
|
||||
|
@ -1268,7 +1090,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val prop = call.argument<String>("prop")
|
||||
if (mimeType == null || uri == null || prop == null) {
|
||||
result.error("getContentPropValue-args", "missing arguments", null)
|
||||
|
@ -1285,7 +1107,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val field = call.argument<String>("field")
|
||||
if (mimeType == null || uri == null || field == null) {
|
||||
|
@ -1297,7 +1119,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
val tag = when (field) {
|
||||
ExifInterface.TAG_DATETIME -> ExifIFD0Directory.TAG_DATETIME
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED -> ExifSubIFDDirectory.TAG_DATETIME_DIGITIZED
|
||||
|
@ -1352,61 +1174,55 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(dateMillis)
|
||||
}
|
||||
|
||||
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
||||
// return description from these fields (by precedence):
|
||||
// - XMP / dc:description
|
||||
// - IPTC / caption-abstract
|
||||
// - Exif / UserComment
|
||||
// - Exif / ImageDescription
|
||||
private fun getDescription(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.toUri()
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||
val fields = call.argument<List<String>>("fields")
|
||||
if (mimeType == null || uri == null || fields == null) {
|
||||
result.error("getFields-args", "missing arguments", null)
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getDescription-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, Any?>()
|
||||
|
||||
val hashFields = fields.filter { it.startsWith(HASH_FIELD_PREFIX) }.toSet()
|
||||
metadataMap.putAll(getHashFields(uri, mimeType, sizeBytes, hashFields))
|
||||
|
||||
val exifFields = fields.filterNot { hashFields.contains(it) }.toSet()
|
||||
metadataMap.putAll(getExifFields(uri, mimeType, sizeBytes, exifFields))
|
||||
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getHashFields(uri: Uri, mimeType: String, sizeBytes: Long?, fields: Set<String>): FieldMap {
|
||||
val metadataMap = HashMap<String, Any?>()
|
||||
fields.forEach { field ->
|
||||
val function = field.substringAfter(HASH_FIELD_PREFIX).lowercase(Locale.ROOT)
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
metadataMap[field] = HashUtils.getHash(input, function)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get hash for mimeType=$mimeType uri=$uri function=$function", e)
|
||||
}
|
||||
}
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
private fun getExifFields(uri: Uri, mimeType: String, sizeBytes: Long?, fields: Set<String>): FieldMap {
|
||||
val metadataMap = HashMap<String, Any?>()
|
||||
if (fields.isEmpty() || isVideo(mimeType)) {
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
var foundExif = false
|
||||
var description: String? = null
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifDirectoryBase::class.java)) {
|
||||
foundExif = true
|
||||
val allTags = ExifInterfaceHelper.allTags
|
||||
fields.forEach { tag ->
|
||||
allTags[tag]?.let { mapper ->
|
||||
val tagType = mapper.type
|
||||
dir.getDescription(tagType)?.let { value -> metadataMap[tag] = value }
|
||||
val metadata = Helper.safeRead(input)
|
||||
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
val xmpMeta = dir.xmpMeta
|
||||
try {
|
||||
if (xmpMeta.doesPropExist(XMP.DC_DESCRIPTION_PROP_NAME)) {
|
||||
xmpMeta.getSafeLocalizedText(XMP.DC_DESCRIPTION_PROP_NAME, acceptBlank = false) { description = it }
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
|
||||
dir.getSafeString(IptcDirectory.TAG_CAPTION, acceptBlank = false) { description = it }
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
// user comment field specifies encoding, unlike other string fields
|
||||
if (dir.containsTag(ExifSubIFDDirectory.TAG_USER_COMMENT)) {
|
||||
val string = dir.getDescription(ExifSubIFDDirectory.TAG_USER_COMMENT)
|
||||
if (string.isNotBlank()) {
|
||||
description = string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (description == null) {
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
dir.getSafeString(ExifIFD0Directory.TAG_IMAGE_DESCRIPTION, acceptBlank = false) { description = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1419,28 +1235,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif && canReadWithExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
fields.forEach { tag ->
|
||||
if (exif.hasAttribute(tag)) {
|
||||
val value = exif.getAttribute(tag)
|
||||
if (value != null) {
|
||||
metadataMap[tag] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
return metadataMap
|
||||
result.success(description)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -1502,12 +1297,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
private const val MASK_IS_360 = 1 shl 3
|
||||
private const val MASK_IS_MULTIPAGE = 1 shl 4
|
||||
private const val MASK_IS_MOTION_PHOTO = 1 shl 5
|
||||
private const val MASK_IS_HDR = 1 shl 6 // for images: embedded HDR gainmap, for videos: HDR color transfer
|
||||
private const val XMP_SUBJECTS_SEPARATOR = ";"
|
||||
|
||||
// overlay metadata
|
||||
private const val KEY_APERTURE = "aperture"
|
||||
private const val KEY_DESCRIPTION = "description"
|
||||
private const val KEY_EXPOSURE_TIME = "exposureTime"
|
||||
private const val KEY_FOCAL_LENGTH = "focalLength"
|
||||
private const val KEY_ISO = "iso"
|
||||
|
@ -1515,7 +1308,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
// additional media key
|
||||
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
|
||||
|
||||
private const val HASH_FIELD_PREFIX = "hash"
|
||||
private const val VALUE_SKIPPED_DATA = "[skipped]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ package deckers.thibault.aves.channel.calls
|
|||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
|
@ -45,8 +44,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val preferences = getStore()
|
||||
preferences.edit {
|
||||
with(getStore().edit()) {
|
||||
when (value) {
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is Float -> putFloat(key, value)
|
||||
|
@ -59,6 +57,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
}
|
||||
apply()
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
|
|
|
@ -29,9 +29,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
|
||||
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
||||
"getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
|
||||
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
|
||||
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
|
||||
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
||||
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
|
||||
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
|
||||
|
@ -40,7 +37,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"deleteTempDirectory" -> ioScope.launch { safe(call, result, ::deleteTempDirectory) }
|
||||
"deleteExternalCache" -> ioScope.launch { safe(call, result, ::deleteExternalCache) }
|
||||
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
|
||||
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
|
||||
else -> result.notImplemented()
|
||||
|
@ -49,19 +45,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getDataUsage(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
var internalCache = getFolderSize(context.cacheDir)
|
||||
internalCache += getFolderSize(context.codeCacheDir)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
internalCache += getFolderSize(context.codeCacheDir)
|
||||
}
|
||||
val externalCache = context.externalCacheDirs.map(::getFolderSize).sum()
|
||||
val externalFilesDirs = context.getExternalFilesDirs(null)
|
||||
|
||||
val dataDir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.dataDir else File(context.applicationInfo.dataDir)
|
||||
|
||||
val database = getFolderSize(File(dataDir, "databases"))
|
||||
val flutter = getFolderSize(File(PathUtils.getDataDirectory(context)))
|
||||
val vaults = getFolderSize(File(StorageUtils.getVaultRoot(context)))
|
||||
val trash = externalFilesDirs.mapNotNull { StorageUtils.trashDirFor(context, it.path) }.map(::getFolderSize).sum()
|
||||
val trash = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) }.map(::getFolderSize).sum()
|
||||
|
||||
val internalData = getFolderSize(dataDir) - internalCache
|
||||
val externalData = externalFilesDirs.map(::getFolderSize).sum()
|
||||
val externalData = context.getExternalFilesDirs(null).map(::getFolderSize).sum()
|
||||
val miscData = internalData + externalData - (database + flutter + vaults + trash)
|
||||
|
||||
result.success(
|
||||
|
@ -106,7 +103,12 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
val volumeFile = File(volumePath)
|
||||
try {
|
||||
val isPrimary = volumePath == primaryVolumePath
|
||||
val isRemovable = Environment.isExternalStorageRemovable(volumeFile)
|
||||
val isRemovable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
Environment.isExternalStorageRemovable(volumeFile)
|
||||
} else {
|
||||
// random guess
|
||||
!isPrimary
|
||||
}
|
||||
volumes.add(
|
||||
hashMapOf(
|
||||
"path" to volumePath,
|
||||
|
@ -123,47 +125,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(volumes)
|
||||
}
|
||||
|
||||
private fun getCacheDirectory(call: MethodCall, result: MethodChannel.Result) {
|
||||
val external = call.argument<Boolean>("external")
|
||||
if (external == null) {
|
||||
result.error("getCacheDirectory-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val dir = (if (external) context.externalCacheDir else context.cacheDir)
|
||||
result.success(dir!!.path)
|
||||
}
|
||||
|
||||
|
||||
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
|
||||
val knownPaths = call.argument<List<String>>("knownPaths")
|
||||
if (knownPaths == null) {
|
||||
result.error("getUntrackedTrashPaths-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val trashDirs = context.getExternalFilesDirs(null).filterNotNull().mapNotNull { StorageUtils.trashDirFor(context, it.path) }
|
||||
val trashItemPaths = trashDirs.flatMap { dir -> dir.listFiles()?.filterNotNull()?.mapNotNull { file -> file.path } ?: listOf() }
|
||||
val untrackedPaths = trashItemPaths.filterNot(knownPaths::contains).toList()
|
||||
|
||||
result.success(untrackedPaths)
|
||||
}
|
||||
|
||||
private fun getUntrackedVaultPaths(call: MethodCall, result: MethodChannel.Result) {
|
||||
val vault = call.argument<String>("vault")
|
||||
val knownPaths = call.argument<List<String>>("knownPaths")
|
||||
if (vault == null || knownPaths == null) {
|
||||
result.error("getUntrackedVaultPaths-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val vaultDir = File(StorageUtils.getVaultRoot(context), vault)
|
||||
val vaultItemPaths = vaultDir.listFiles()?.mapNotNull { file -> file?.path } ?: listOf()
|
||||
val untrackedPaths = vaultItemPaths.filterNot(knownPaths::contains).toList()
|
||||
|
||||
result.success(untrackedPaths)
|
||||
}
|
||||
|
||||
private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(StorageUtils.getVaultRoot(context))
|
||||
}
|
||||
|
@ -210,6 +171,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
result.error("revokeDirectoryAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
|
||||
return
|
||||
}
|
||||
|
||||
val success = PermissionManager.revokeDirectoryAccess(context, path)
|
||||
result.success(success)
|
||||
}
|
||||
|
@ -239,11 +205,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(StorageUtils.deleteTempDirectory(context))
|
||||
}
|
||||
|
||||
private fun deleteExternalCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
context.externalCacheDirs.filter { it.exists() }.forEach { it.deleteRecursively() }
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
}
|
||||
|
|
|
@ -4,37 +4,33 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.ColorSpace
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||
import deckers.thibault.aves.decoder.MultiPageImage
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MathUtils
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// As of Android 14 (API 34), `BitmapRegionDecoder` documentation states
|
||||
// that "only the JPEG, PNG, WebP and HEIF formats are supported"
|
||||
// but in practice it successfully decodes some others.
|
||||
class RegionFetcher internal constructor(
|
||||
private val context: Context,
|
||||
) {
|
||||
// returns decoded bytes in ARGB_8888, with trailer bytes:
|
||||
// - width (int32)
|
||||
// - height (int32)
|
||||
fun fetch(
|
||||
private var lastDecoderRef: LastDecoderRef? = null
|
||||
|
||||
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
||||
|
||||
private val multiTrackGlideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
suspend fun fetch(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
pageId: Int?,
|
||||
|
@ -42,117 +38,78 @@ class RegionFetcher internal constructor(
|
|||
regionRect: Rect,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||
// use JPEG export for requested page
|
||||
if (MimeTypes.isHeic(mimeType) && pageId != null) {
|
||||
val id = Pair(uri, pageId)
|
||||
fetch(
|
||||
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
||||
mimeType = MimeTypes.JPEG,
|
||||
pageId = null,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
requestKey = requestKey,
|
||||
result = result,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
}
|
||||
|
||||
var currentDecoderRef = lastDecoderRef
|
||||
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
||||
currentDecoderRef = null
|
||||
}
|
||||
|
||||
try {
|
||||
val decoder = getOrCreateDecoder(context, uri, requestKey)
|
||||
if (decoder == null) {
|
||||
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
if (currentDecoderRef == null) {
|
||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
BitmapRegionDecoderCompat.newInstance(input)
|
||||
}
|
||||
if (newDecoder == null) {
|
||||
result.error("getRegion-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
currentDecoderRef = LastDecoderRef(uri, newDecoder)
|
||||
}
|
||||
val decoder = currentDecoderRef.decoder
|
||||
lastDecoderRef = currentDecoderRef
|
||||
|
||||
// with raw images, the known image size may not match the decoded image size
|
||||
// so we scale the requested region accordingly
|
||||
var effectiveRect = regionRect
|
||||
var effectiveSampleSize = sampleSize
|
||||
|
||||
if (imageWidth != decoder.width || imageHeight != decoder.height) {
|
||||
val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) {
|
||||
val xf = decoder.width.toDouble() / imageWidth
|
||||
val yf = decoder.height.toDouble() / imageHeight
|
||||
effectiveRect = Rect(
|
||||
Rect(
|
||||
(regionRect.left * xf).roundToInt(),
|
||||
(regionRect.top * yf).roundToInt(),
|
||||
(regionRect.right * xf).roundToInt(),
|
||||
(regionRect.bottom * yf).roundToInt(),
|
||||
)
|
||||
val factor = MathUtils.highestPowerOf2((1 / max(xf, yf)).roundToInt())
|
||||
if (factor > 1) {
|
||||
effectiveSampleSize = max(1, effectiveSampleSize / factor)
|
||||
}
|
||||
}
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = effectiveSampleSize
|
||||
// Specifying preferred config and color space avoids the need for conversion afterwards,
|
||||
// but may prevent decoding (e.g. from RGBA_1010102 to ARGB_8888 on some devices).
|
||||
inPreferredConfig = PREFERRED_CONFIG
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
}
|
||||
}
|
||||
|
||||
val pixelCount = effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
|
||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), options.inPreferredConfig)
|
||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||
// decoding a region that large would yield an OOM when creating the bitmap
|
||||
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
|
||||
var bitmap = decoder.decodeRegion(effectiveRect, options)
|
||||
if (bitmap == null) {
|
||||
// retry without specifying config or color space,
|
||||
// falling back to custom byte conversion afterwards
|
||||
options.inPreferredConfig = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && options.inPreferredColorSpace != null) {
|
||||
options.inPreferredColorSpace = null
|
||||
}
|
||||
bitmap = decoder.decodeRegion(effectiveRect, options)
|
||||
}
|
||||
|
||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
||||
if (bytes != null) {
|
||||
result.success(bytes)
|
||||
} else {
|
||||
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||
regionRect
|
||||
}
|
||||
|
||||
val bitmap = decoder.decodeRegion(effectiveRect, options)
|
||||
if (bitmap != null) {
|
||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
|
||||
} else {
|
||||
result.error("getRegion-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (mimeType != MimeTypes.JPEG) {
|
||||
// retry with JPEG export on failure,
|
||||
// as some formats are not fully supported by `BitmapRegionDecoder`
|
||||
fetch(
|
||||
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||
mimeType = MimeTypes.JPEG,
|
||||
pageId = null,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
requestKey = requestKey,
|
||||
result = result,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
|
||||
Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId")
|
||||
private fun createJpegForPage(sourceUri: Uri, pageId: Int): Uri {
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||
.apply(multiTrackGlideOptions)
|
||||
.load(MultiTrackImage(context, sourceUri, pageId))
|
||||
.submit()
|
||||
|
||||
try {
|
||||
val bitmap = target.get()
|
||||
val tempFile = StorageUtils.createTempFile(context).apply {
|
||||
|
@ -166,40 +123,8 @@ class RegionFetcher internal constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private data class DecoderRef(
|
||||
val requestKey: Pair<Uri, Int?>,
|
||||
private data class LastDecoderRef(
|
||||
val uri: Uri,
|
||||
val decoder: BitmapRegionDecoder,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
|
||||
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
||||
private const val DECODER_POOL_SIZE = 3
|
||||
private val decoderPool = ArrayList<DecoderRef>()
|
||||
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
|
||||
|
||||
private val poolLock = ReentrantLock()
|
||||
|
||||
private fun getOrCreateDecoder(context: Context, uri: Uri, requestKey: Pair<Uri, Int?>): BitmapRegionDecoder? {
|
||||
poolLock.withLock {
|
||||
var decoderRef = decoderPool.firstOrNull { it.requestKey == requestKey }
|
||||
if (decoderRef == null) {
|
||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
BitmapRegionDecoderCompat.newInstance(input)
|
||||
}
|
||||
if (newDecoder == null) {
|
||||
return null
|
||||
}
|
||||
decoderRef = DecoderRef(requestKey, newDecoder)
|
||||
} else {
|
||||
decoderPool.remove(decoderRef)
|
||||
}
|
||||
decoderPool.add(0, decoderRef)
|
||||
while (decoderPool.size > DECODER_POOL_SIZE) {
|
||||
decoderPool.removeAt(decoderPool.size - 1)
|
||||
}
|
||||
return decoderRef.decoder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,28 +6,25 @@ import android.graphics.Canvas
|
|||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.caverock.androidsvg.PreserveAspectRatio
|
||||
import com.caverock.androidsvg.RenderOptions
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.SVGParseException
|
||||
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.ceil
|
||||
|
||||
class SvgRegionFetcher internal constructor(
|
||||
private val context: Context,
|
||||
) {
|
||||
fun fetch(
|
||||
private var lastSvgRef: LastSvgRef? = null
|
||||
|
||||
suspend fun fetch(
|
||||
uri: Uri,
|
||||
sizeBytes: Long?,
|
||||
scale: Int,
|
||||
regionRect: Rect,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
|
@ -35,23 +32,43 @@ class SvgRegionFetcher internal constructor(
|
|||
) {
|
||||
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
||||
// opening an SVG that large would yield an OOM during parsing from `com.caverock.androidsvg.SVGParser`
|
||||
result.error("fetch-read-large-file", "SVG too large at $sizeBytes bytes, for uri=$uri regionRect=$regionRect", null)
|
||||
result.error("fetch-read-large", "SVG too large at $sizeBytes bytes, for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
|
||||
var currentSvgRef = lastSvgRef
|
||||
if (currentSvgRef != null && currentSvgRef.uri != uri) {
|
||||
currentSvgRef = null
|
||||
}
|
||||
|
||||
try {
|
||||
val svg = getOrCreateDecoder(context, uri)
|
||||
if (svg == null) {
|
||||
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
if (currentSvgRef == null) {
|
||||
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
try {
|
||||
SVG.getFromInputStream(input)
|
||||
} catch (ex: SVGParseException) {
|
||||
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (newSvg == null) {
|
||||
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
|
||||
newSvg.normalizeSize()
|
||||
currentSvgRef = LastSvgRef(uri, newSvg)
|
||||
}
|
||||
val svg = currentSvgRef.svg
|
||||
lastSvgRef = currentSvgRef
|
||||
|
||||
// we scale the requested region accordingly to the viewbox size
|
||||
val viewBox = svg.documentViewBox
|
||||
val svgWidth = viewBox.width()
|
||||
val svgHeight = viewBox.height()
|
||||
val xf = imageWidth / scale / ceil(svgWidth)
|
||||
val yf = imageHeight / scale / ceil(svgHeight)
|
||||
val xf = imageWidth / ceil(svgWidth)
|
||||
val yf = imageHeight / ceil(svgHeight)
|
||||
// some SVG paths do not respect the rendering viewbox and do not reach its edges
|
||||
// so we render to a slightly larger bitmap, using a slightly larger viewbox,
|
||||
// and crop that bitmap to the target region size
|
||||
|
@ -63,7 +80,6 @@ class SvgRegionFetcher internal constructor(
|
|||
(regionRect.right + bleedX) / xf,
|
||||
(regionRect.bottom + bleedY) / yf,
|
||||
)
|
||||
effectiveRect.offset(viewBox.left, viewBox.top)
|
||||
|
||||
val renderOptions = RenderOptions()
|
||||
renderOptions.viewBox(effectiveRect.left, effectiveRect.top, effectiveRect.width(), effectiveRect.height())
|
||||
|
@ -71,65 +87,23 @@ class SvgRegionFetcher internal constructor(
|
|||
|
||||
val targetBitmapWidth = regionRect.width()
|
||||
val targetBitmapHeight = regionRect.height()
|
||||
val canvasWidth = targetBitmapWidth + bleedX * 2
|
||||
val canvasHeight = targetBitmapHeight + bleedY * 2
|
||||
|
||||
val config = PREFERRED_CONFIG
|
||||
val pixelCount = canvasWidth * canvasHeight
|
||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), config)
|
||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||
// decoding a region that large would yield an OOM when creating the bitmap
|
||||
result.error("fetch-read-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
|
||||
return
|
||||
}
|
||||
|
||||
var bitmap = createBitmap(canvasWidth, canvasHeight, config)
|
||||
var bitmap = Bitmap.createBitmap(
|
||||
targetBitmapWidth + bleedX * 2,
|
||||
targetBitmapHeight + bleedY * 2,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(bitmap)
|
||||
svg.renderToCanvas(canvas, renderOptions)
|
||||
|
||||
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
||||
result.success(bytes)
|
||||
} catch (e: SVGParseException) {
|
||||
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||
} catch (e: Exception) {
|
||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DecoderRef(
|
||||
private data class LastSvgRef(
|
||||
val uri: Uri,
|
||||
val decoder: SVG,
|
||||
val svg: SVG,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
||||
private const val DECODER_POOL_SIZE = 3
|
||||
private val decoderPool = ArrayList<DecoderRef>()
|
||||
|
||||
private val poolLock = ReentrantLock()
|
||||
|
||||
private fun getOrCreateDecoder(context: Context, uri: Uri): SVG? {
|
||||
poolLock.withLock {
|
||||
var decoderRef = decoderPool.firstOrNull { it.uri == uri }
|
||||
if (decoderRef == null) {
|
||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
|
||||
}
|
||||
if (newDecoder == null) {
|
||||
return null
|
||||
}
|
||||
newDecoder.normalizeSize()
|
||||
decoderRef = DecoderRef(uri, newDecoder)
|
||||
} else {
|
||||
decoderPool.remove(decoderRef)
|
||||
}
|
||||
decoderPool.add(0, decoderRef)
|
||||
while (decoderPool.size > DECODER_POOL_SIZE) {
|
||||
decoderPool.removeAt(decoderPool.size - 1)
|
||||
}
|
||||
return decoderRef.decoder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,37 +5,34 @@ import android.graphics.Bitmap
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||
import deckers.thibault.aves.decoder.MultiPageImage
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.SvgImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.SVG
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ThumbnailFetcher internal constructor(
|
||||
private val context: Context,
|
||||
uri: String,
|
||||
private val mimeType: String,
|
||||
private val dateModifiedMillis: Long,
|
||||
private val dateModifiedSecs: Long,
|
||||
private val rotationDegrees: Int,
|
||||
private val isFlipped: Boolean,
|
||||
width: Int?,
|
||||
|
@ -45,15 +42,15 @@ class ThumbnailFetcher internal constructor(
|
|||
private val quality: Int,
|
||||
private val result: MethodChannel.Result,
|
||||
) {
|
||||
private val uri: Uri = uri.toUri()
|
||||
private val uri: Uri = Uri.parse(uri)
|
||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||
private val svgFetch = mimeType == SVG
|
||||
private val tiffFetch = mimeType == MimeTypes.TIFF
|
||||
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
|
||||
private val customFetch = svgFetch || tiffFetch || multiPageFetch
|
||||
private val multiTrackFetch = isHeic(mimeType) && pageId != null
|
||||
private val customFetch = svgFetch || tiffFetch || multiTrackFetch
|
||||
|
||||
fun fetch() {
|
||||
suspend fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
var exception: Exception? = null
|
||||
|
||||
|
@ -83,33 +80,7 @@ class ThumbnailFetcher internal constructor(
|
|||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
if (bitmap.width > width && bitmap.height > height) {
|
||||
val scalingFactor: Double = min(bitmap.width.toDouble() / width, bitmap.height.toDouble() / height)
|
||||
val dstWidth = (bitmap.width / scalingFactor).roundToInt()
|
||||
val dstHeight = (bitmap.height / scalingFactor).roundToInt()
|
||||
Log.d(
|
||||
LOG_TAG, "rescale thumbnail for mimeType=$mimeType uri=$uri width=$width height=$height" +
|
||||
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height}" +
|
||||
", to target=${dstWidth}x${dstHeight}"
|
||||
)
|
||||
bitmap = bitmap.scale(dstWidth, dstHeight)
|
||||
}
|
||||
|
||||
if (bitmap.byteCount > BITMAP_SIZE_DANGER_THRESHOLD) {
|
||||
result.error(
|
||||
"getThumbnail-large", "thumbnail bitmap dangerously large" +
|
||||
" for mimeType=$mimeType uri=$uri pageId=$pageId width=$width height=$height" +
|
||||
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height} config=${bitmap.config?.name}", null
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// do not recycle bitmaps fetched from `ContentResolver` or Glide as their lifecycle is unknown
|
||||
val recycle = false
|
||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
||||
if (bytes != null) {
|
||||
result.success(bytes)
|
||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
|
||||
} else {
|
||||
var errorDetails: String? = exception?.message
|
||||
if (errorDetails?.isNotEmpty() == true) {
|
||||
|
@ -150,21 +121,33 @@ class ThumbnailFetcher internal constructor(
|
|||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
var options = RequestOptions()
|
||||
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
||||
.signature(ObjectKey("$dateModifiedMillis-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||
.override(width, height)
|
||||
if (isVideo(mimeType)) {
|
||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
}
|
||||
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||
.submit(width, height)
|
||||
val target = if (isVideo(mimeType)) {
|
||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
val model: Any = when {
|
||||
svgFetch -> SvgImage(context, uri)
|
||||
tiffFetch -> TiffImage(context, uri, pageId)
|
||||
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
||||
}
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(model)
|
||||
.submit(width, height)
|
||||
}
|
||||
|
||||
return try {
|
||||
var bitmap = target.get()
|
||||
if (needRotationAfterGlide(mimeType, pageId)) {
|
||||
if (needRotationAfterGlide(mimeType)) {
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
bitmap
|
||||
|
@ -172,9 +155,4 @@ class ThumbnailFetcher internal constructor(
|
|||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ThumbnailFetcher>()
|
||||
private const val BITMAP_SIZE_DANGER_THRESHOLD = 20 * (1 shl 20) // MB
|
||||
}
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
package deckers.thibault.aves.channel.calls.fetchers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.beyka.tiffbitmapfactory.DecodeArea
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
@ -12,7 +11,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|||
class TiffRegionFetcher internal constructor(
|
||||
private val context: Context,
|
||||
) {
|
||||
fun fetch(
|
||||
suspend fun fetch(
|
||||
uri: Uri,
|
||||
page: Int,
|
||||
sampleSize: Int,
|
||||
|
@ -32,10 +31,9 @@ class TiffRegionFetcher internal constructor(
|
|||
inSampleSize = sampleSize
|
||||
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
||||
}
|
||||
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
||||
if (bytes != null) {
|
||||
result.success(bytes)
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap != null) {
|
||||
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||
} else {
|
||||
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls.window
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.view.WindowManager
|
||||
import deckers.thibault.aves.utils.getDisplayCompat
|
||||
|
@ -76,32 +75,4 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenWideColorGamut)
|
||||
}
|
||||
|
||||
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenHdr)
|
||||
}
|
||||
|
||||
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||
val wideColorGamut = call.argument<Boolean>("wideColorGamut")
|
||||
val hdr = call.argument<Boolean>("hdr")
|
||||
if (wideColorGamut == null || hdr == null) {
|
||||
result.error("setColorMode-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.window.colorMode = if (hdr) {
|
||||
ActivityInfo.COLOR_MODE_HDR
|
||||
} else if (wideColorGamut) {
|
||||
ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
||||
} else {
|
||||
ActivityInfo.COLOR_MODE_DEFAULT
|
||||
}
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
}
|
|
@ -28,16 +28,4 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
|
|||
override fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(HashMap<String, Any>())
|
||||
}
|
||||
|
||||
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(false)
|
||||
}
|
||||
|
||||
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(false)
|
||||
}
|
||||
|
||||
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(null)
|
||||
}
|
||||
}
|
|
@ -18,9 +18,6 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
|||
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
||||
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
||||
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
|
||||
"supportsWideGamut" -> Coresult.safe(call, result, ::supportsWideGamut)
|
||||
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
|
||||
"setColorMode" -> Coresult.safe(call, result, ::setColorMode)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -47,12 +44,6 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
|||
|
||||
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
abstract fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
abstract fun supportsHdr(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
abstract fun setColorMode(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/window"
|
||||
|
|
|
@ -7,10 +7,8 @@ import android.os.Build
|
|||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
|
@ -49,8 +47,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
||||
"createFile" -> ioScope.launch { createFile() }
|
||||
"openFile" -> ioScope.launch { openFile() }
|
||||
"copyFile" -> ioScope.launch { copyFile() }
|
||||
"edit" -> edit()
|
||||
"pickCollectionFilters" -> pickCollectionFilters()
|
||||
else -> endOfStream()
|
||||
}
|
||||
|
@ -63,6 +59,11 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
error("requestDirectoryAccess-unsupported", "directory access is not allowed before Android Lollipop", null)
|
||||
return
|
||||
}
|
||||
|
||||
PermissionManager.requestDirectoryAccess(activity, ensureTrailingSeparator(path), {
|
||||
success(true)
|
||||
endOfStream()
|
||||
|
@ -73,7 +74,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
}
|
||||
|
||||
private fun requestMediaFileAccess() {
|
||||
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) it.toUri() else null }
|
||||
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
|
||||
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
||||
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
||||
error("requestMediaFileAccess-args", "missing arguments", null)
|
||||
|
@ -99,13 +100,10 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
endOfStream()
|
||||
}
|
||||
|
||||
private suspend fun safeStartActivityForStorageAccessResult(intent: Intent, requestCode: Int, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
|
||||
private suspend fun safeStartActivityForResult(intent: Intent, requestCode: Int, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
|
||||
if (intent.resolveActivity(activity.packageManager) != null) {
|
||||
MainActivity.pendingStorageAccessResultHandlers[requestCode] = PendingStorageAccessResultHandler(null, onGranted, onDenied)
|
||||
if (!safeStartActivityForResult(intent, requestCode)) {
|
||||
MainActivity.notifyError("failed to start activity for intent=$intent extras=${intent.extras}")
|
||||
onDenied()
|
||||
}
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
} else {
|
||||
MainActivity.notifyError("failed to resolve activity for intent=$intent extras=${intent.extras}")
|
||||
onDenied()
|
||||
|
@ -146,7 +144,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||
safeStartActivityForResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||
}
|
||||
|
||||
private suspend fun openFile() {
|
||||
|
@ -179,76 +177,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
setTypeAndNormalize(mimeType ?: MimeTypes.ANY)
|
||||
}
|
||||
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||
}
|
||||
|
||||
private suspend fun copyFile() {
|
||||
val name = args["name"] as String?
|
||||
val mimeType = args["mimeType"] as String?
|
||||
val sourceUri = (args["sourceUri"] as String?)?.toUri()
|
||||
if (name == null || mimeType == null || sourceUri == null) {
|
||||
error("copyFile-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
fun onGranted(uri: Uri) {
|
||||
ioScope.launch {
|
||||
try {
|
||||
StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
|
||||
// truncate is necessary when overwriting a longer file
|
||||
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var len: Int
|
||||
while (input.read(buffer).also { len = it } != -1) {
|
||||
output.write(buffer, 0, len)
|
||||
}
|
||||
}
|
||||
}
|
||||
success(true)
|
||||
} catch (e: Exception) {
|
||||
error("copyFile-write", "failed to copy file from sourceUri=$sourceUri to uri=$uri", e.message)
|
||||
}
|
||||
endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
fun onDenied() {
|
||||
success(null)
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||
}
|
||||
|
||||
private fun edit() {
|
||||
val uri = args["uri"] as String?
|
||||
val mimeType = args["mimeType"] as String? // optional
|
||||
if (uri == null) {
|
||||
error("edit-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_EDIT)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
|
||||
|
||||
if (intent.resolveActivity(activity.packageManager) == null) {
|
||||
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
||||
return
|
||||
}
|
||||
|
||||
MainActivity.pendingEditIntentHandler = { fields ->
|
||||
success(fields)
|
||||
endOfStream()
|
||||
}
|
||||
if (!safeStartActivityForResult(intent, MainActivity.EDIT_REQUEST)) {
|
||||
error("edit-start", "cannot start activity for this intent for uri=$uri mimeType=$mimeType", null)
|
||||
}
|
||||
safeStartActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
|
||||
}
|
||||
|
||||
private fun pickCollectionFilters() {
|
||||
|
@ -263,24 +192,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
activity.startActivityForResult(intent, MainActivity.PICK_COLLECTION_FILTERS_REQUEST)
|
||||
}
|
||||
|
||||
private fun safeStartActivityForResult(intent: Intent, requestCode: Int): Boolean {
|
||||
return try {
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
if (intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
|
||||
// in some environments, providing the write flag yields a `SecurityException`:
|
||||
// "UID XXXX does not have permission to content://XXXX"
|
||||
// so we retry without it
|
||||
Log.i(LOG_TAG, "retry intent=$intent without FLAG_GRANT_WRITE_URI_PERMISSION")
|
||||
intent.flags = intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION.inv()
|
||||
safeStartActivityForResult(intent, requestCode)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
Log.i(LOG_TAG, "onCancel arguments=$arguments")
|
||||
}
|
||||
|
@ -319,6 +230,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ActivityResultStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/activity_result_stream"
|
||||
private const val BUFFER_SIZE = 1 shl 18 // 256kB
|
||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||
}
|
||||
}
|
|
@ -5,15 +5,20 @@ import android.net.Uri
|
|||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -24,7 +29,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
|
@ -81,13 +85,11 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
return
|
||||
}
|
||||
|
||||
val decoded = arguments["decoded"] as Boolean
|
||||
val mimeType = arguments["mimeType"] as String?
|
||||
val uri = (arguments["uri"] as String?)?.toUri()
|
||||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||
val isFlipped = arguments["isFlipped"] as Boolean
|
||||
val isAnimated = arguments["isAnimated"] as Boolean
|
||||
val pageId = arguments["pageId"] as Int?
|
||||
|
||||
if (mimeType == null || uri == null) {
|
||||
|
@ -96,31 +98,19 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
return
|
||||
}
|
||||
|
||||
if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
|
||||
// to be decoded by Flutter
|
||||
streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
|
||||
} else if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
sizeBytes = sizeBytes,
|
||||
decoded = decoded,
|
||||
)
|
||||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri, mimeType, sizeBytes)
|
||||
} else if (!canDecodeWithFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
|
||||
} else {
|
||||
streamImageByGlide(
|
||||
uri = uri,
|
||||
pageId = pageId,
|
||||
mimeType = mimeType,
|
||||
sizeBytes = sizeBytes,
|
||||
rotationDegrees = rotationDegrees,
|
||||
isFlipped = isFlipped,
|
||||
decoded = decoded,
|
||||
)
|
||||
// to be decoded by Flutter
|
||||
streamImageAsIs(uri, mimeType, sizeBytes)
|
||||
}
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
||||
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||
return
|
||||
|
@ -140,29 +130,29 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
sizeBytes: Long?,
|
||||
rotationDegrees: Int,
|
||||
isFlipped: Boolean,
|
||||
decoded: Boolean,
|
||||
) {
|
||||
val model: Any = if (isHeic(mimeType) && pageId != null) {
|
||||
MultiTrackImage(context, uri, pageId)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, uri, pageId)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||
}
|
||||
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
.submit()
|
||||
try {
|
||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||
if (needRotationAfterGlide(mimeType, pageId)) {
|
||||
if (needRotationAfterGlide(mimeType)) {
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
|
||||
val recycle = false
|
||||
val bytes = if (decoded) {
|
||||
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
||||
} else {
|
||||
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = MimeTypes.canHaveAlpha(mimeType), recycle = recycle)
|
||||
}
|
||||
|
||||
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
|
||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||
streamBytes(ByteArrayInputStream(bytes))
|
||||
success(bytes)
|
||||
} else {
|
||||
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
|
@ -170,31 +160,24 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", toErrorDetails(e))
|
||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
||||
} finally {
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?, decoded: Boolean) {
|
||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
|
||||
.apply(glideOptions)
|
||||
.load(VideoThumbnail(context, uri))
|
||||
.submit()
|
||||
try {
|
||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||
if (bitmap != null) {
|
||||
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
|
||||
val recycle = false
|
||||
val bytes = if (decoded) {
|
||||
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
||||
} else {
|
||||
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = false, recycle = recycle)
|
||||
}
|
||||
|
||||
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
|
||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||
streamBytes(ByteArrayInputStream(bytes))
|
||||
success(bytes)
|
||||
} else {
|
||||
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
|
@ -236,5 +219,11 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
|||
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
||||
|
||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
private val glideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
@ -21,8 +21,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
class ImageOpStreamHandler(private val activity: FragmentActivity, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
@ -106,7 +107,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
result["skipped"] = true
|
||||
} else {
|
||||
result["success"] = false
|
||||
getProvider(activity, uri)?.let { provider ->
|
||||
getProvider(uri)?.let { provider ->
|
||||
try {
|
||||
provider.delete(activity, uri, path, mimeType)
|
||||
result["success"] = true
|
||||
|
@ -141,7 +142,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
|
||||
// assume same provider for all entries
|
||||
val firstEntry = entryMapList.first()
|
||||
val provider = (firstEntry["uri"] as String?)?.toUri()?.let { getProvider(activity, it) }
|
||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||
if (provider == null) {
|
||||
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
||||
return
|
||||
|
@ -230,7 +231,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
entriesToNewName[AvesEntry(rawEntry)] = newName
|
||||
}
|
||||
|
||||
val byProvider = entriesToNewName.entries.groupBy { kv -> getProvider(activity, kv.key.uri) }
|
||||
val byProvider = entriesToNewName.entries.groupBy { kv -> getProvider(kv.key.uri) }
|
||||
for ((provider, entryList) in byProvider) {
|
||||
if (provider == null) {
|
||||
error("rename-provider", "failed to find provider for entry=${entryList.firstOrNull()}", null)
|
||||
|
|
|
@ -30,24 +30,12 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
|
|||
}
|
||||
|
||||
init {
|
||||
Log.i(LOG_TAG, "start listening to Media Store")
|
||||
try {
|
||||
context.contentResolver.apply {
|
||||
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// Trying to register an observer may yield a security exception with this message:
|
||||
// "Failed to find provider media for user 0; expected to find a valid ContentProvider for this authority"
|
||||
Log.w(LOG_TAG, "failed to register content observer", e)
|
||||
context.contentResolver.apply {
|
||||
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
Log.i(LOG_TAG, "stop listening to Media Store")
|
||||
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
@ -57,6 +45,10 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
|
|||
Log.i(LOG_TAG, "onCancel arguments=$arguments")
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||
}
|
||||
|
||||
private fun success(uri: String?) {
|
||||
handler?.post {
|
||||
try {
|
||||
|
|
|
@ -19,13 +19,13 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
|
||||
// knownEntries: map of contentId -> dateModifiedMillis
|
||||
private var knownEntries: Map<Long?, Long?>? = null
|
||||
private var knownEntries: Map<Int?, Int?>? = null
|
||||
private var directory: String? = null
|
||||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap()
|
||||
@Suppress("unchecked_cast")
|
||||
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
|
||||
directory = arguments["directory"] as String?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.ViewConfiguration
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
@ -22,7 +21,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
private val contentObserver = object : ContentObserver(null) {
|
||||
private var accelerometerRotation: Int = 0
|
||||
private var transitionAnimationScale: Float = 1f
|
||||
private var longPressTimeoutMillis: Int = 0
|
||||
|
||||
init {
|
||||
update()
|
||||
|
@ -38,7 +36,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
hashMapOf(
|
||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
||||
KEY_LONG_PRESS_TIMEOUT_MILLIS to longPressTimeoutMillis,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -57,11 +54,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
transitionAnimationScale = newTransitionAnimationScale
|
||||
changed = true
|
||||
}
|
||||
val newLongPressTimeout = ViewConfiguration.getLongPressTimeout()
|
||||
if (longPressTimeoutMillis != newLongPressTimeout) {
|
||||
longPressTimeoutMillis = newLongPressTimeout
|
||||
changed = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||
}
|
||||
|
@ -70,13 +62,9 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
}
|
||||
|
||||
init {
|
||||
Log.i(LOG_TAG, "start listening to system settings")
|
||||
context.contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, contentObserver)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
Log.i(LOG_TAG, "stop listening to system settings")
|
||||
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||
context.contentResolver.apply {
|
||||
registerContentObserver(Settings.System.CONTENT_URI, true, contentObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
|
@ -88,6 +76,10 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
Log.i(LOG_TAG, "onCancel arguments=$arguments")
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||
}
|
||||
|
||||
private fun success(settings: FieldMap) {
|
||||
handler?.post {
|
||||
try {
|
||||
|
@ -101,8 +93,5 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
||||
|
||||
// cf `Settings.Secure.LONG_PRESS_TIMEOUT`
|
||||
const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout"
|
||||
}
|
||||
}
|
|
@ -1,30 +1,14 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
|
||||
import com.bumptech.glide.load.engine.cache.DiskCache
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.engine.cache.LruResourceCache
|
||||
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
|
||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.compatRemoveIf
|
||||
|
||||
@GlideModule
|
||||
|
@ -32,30 +16,6 @@ class AvesAppGlideModule : AppGlideModule() {
|
|||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
// hide noisy warning (e.g. for images that can't be decoded)
|
||||
builder.setLogLevel(Log.ERROR)
|
||||
|
||||
// sizing
|
||||
val memorySizeCalculator = MemorySizeCalculator.Builder(context).build()
|
||||
builder.setMemorySizeCalculator(memorySizeCalculator)
|
||||
val size: Int = memorySizeCalculator.bitmapPoolSize
|
||||
if (size > 0) {
|
||||
builder.setBitmapPool(LruBitmapPool(size.toLong()))
|
||||
} else {
|
||||
builder.setBitmapPool(BitmapPoolAdapter())
|
||||
}
|
||||
builder.setArrayPool(LruArrayPool(memorySizeCalculator.arrayPoolSizeInBytes))
|
||||
builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong()))
|
||||
|
||||
val diskCacheSize = DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE
|
||||
val internalCacheDiskCacheFactory = InternalCacheDiskCacheFactory(context, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR, diskCacheSize.toLong())
|
||||
builder.setDiskCache(internalCacheDiskCacheFactory)
|
||||
|
||||
fun toMb(bytes: Int) = Formatter.formatFileSize(context, bytes.toLong())
|
||||
Log.d(
|
||||
LOG_TAG, "Glide disk cache size=${toMb(diskCacheSize)}" +
|
||||
", memory cache size=${toMb(memorySizeCalculator.memoryCacheSize)}" +
|
||||
", bitmap pool size=${toMb(memorySizeCalculator.bitmapPoolSize)}" +
|
||||
", array pool size=${toMb(memorySizeCalculator.arrayPoolSizeInBytes)}"
|
||||
)
|
||||
}
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
|
@ -65,28 +25,4 @@ class AvesAppGlideModule : AppGlideModule() {
|
|||
}
|
||||
|
||||
override fun isManifestParsingEnabled(): Boolean = false
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<AvesAppGlideModule>()
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
val uncachedFullImageOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
|
||||
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||
MultiPageImage(context, uri, mimeType, pageId)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, uri, pageId)
|
||||
} else if (mimeType == MimeTypes.SVG) {
|
||||
SvgImage(context, uri)
|
||||
} else if (isVideo(mimeType)) {
|
||||
VideoThumbnail(context, uri)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,40 +17,32 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
|
|||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.MultiTrackMedia
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
||||
@GlideModule
|
||||
class MultiPageImageGlideModule : LibraryGlideModule() {
|
||||
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.append(MultiPageImage::class.java, Bitmap::class.java, MultiPageThumbnailLoader.Factory())
|
||||
registry.append(MultiTrackImage::class.java, Bitmap::class.java, MultiTrackThumbnailLoader.Factory())
|
||||
}
|
||||
}
|
||||
|
||||
class MultiPageImage(val context: Context, val uri: Uri, val mimeType: String, val pageId: Int?) {
|
||||
override fun toString(): String = "MultiPageImage#${hashCode()}{uri=$uri, mimeType=$mimeType, pageId=$pageId}"
|
||||
class MultiTrackImage(val context: Context, val uri: Uri, val trackIndex: Int?)
|
||||
|
||||
companion object {
|
||||
fun isSupported(mimeType: String) = MimeTypes.isHeic(mimeType) || mimeType == MimeTypes.JPEG
|
||||
}
|
||||
}
|
||||
|
||||
internal class MultiPageThumbnailLoader : ModelLoader<MultiPageImage, Bitmap> {
|
||||
override fun buildLoadData(model: MultiPageImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), MultiPageImageFetcher(model, width, height))
|
||||
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
|
||||
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
|
||||
}
|
||||
|
||||
override fun handles(model: MultiPageImage): Boolean = true
|
||||
override fun handles(model: MultiTrackImage): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<MultiPageImage, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiPageImage, Bitmap> = MultiPageThumbnailLoader()
|
||||
internal class Factory : ModelLoaderFactory<MultiTrackImage, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader()
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
internal class MultiPageImageFetcher(val model: MultiPageImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
callback.onLoadFailed(Exception("unsupported Android version"))
|
||||
|
@ -59,17 +51,9 @@ internal class MultiPageImageFetcher(val model: MultiPageImage, val width: Int,
|
|||
|
||||
val context = model.context
|
||||
val uri = model.uri
|
||||
val mimeType = model.mimeType
|
||||
|
||||
var bitmap: Bitmap? = null
|
||||
if (MimeTypes.isHeic(mimeType)) {
|
||||
val trackIndex = model.pageId
|
||||
bitmap = MultiTrackMedia.getImage(context, uri, trackIndex)
|
||||
} else if (mimeType == MimeTypes.JPEG) {
|
||||
val pageIndex = model.pageId ?: 0
|
||||
bitmap = MultiPage.getJpegMpfBitmap(context, uri, pageIndex)
|
||||
}
|
||||
val trackIndex = model.trackIndex
|
||||
|
||||
val bitmap = MultiTrackMedia.getImage(context, uri, trackIndex)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
|
@ -19,7 +18,6 @@ import com.bumptech.glide.module.LibraryGlideModule
|
|||
import com.bumptech.glide.signature.ObjectKey
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.caverock.androidsvg.SVGParseException
|
||||
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import kotlin.math.ceil
|
||||
|
@ -54,7 +52,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
|
|||
|
||||
val bitmap: Bitmap? = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
try {
|
||||
SVG.getFromInputStream(SVGParserBufferedInputStream(input))?.let { svg ->
|
||||
SVG.getFromInputStream(input)?.let { svg ->
|
||||
svg.normalizeSize()
|
||||
val viewBox = svg.documentViewBox
|
||||
val svgWidth = viewBox.width()
|
||||
|
@ -62,14 +60,14 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
|
|||
|
||||
val bitmapWidth: Int
|
||||
val bitmapHeight: Int
|
||||
if (width / height.toFloat() > svgWidth / svgHeight) {
|
||||
if (width / height > svgWidth / svgHeight) {
|
||||
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
||||
bitmapHeight = height
|
||||
} else {
|
||||
bitmapWidth = width
|
||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||
}
|
||||
val bitmap = createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
||||
|
||||
val canvas = Canvas(bitmap)
|
||||
svg.renderToCanvas(canvas)
|
||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.decoder
|
|||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.scale
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
|
@ -49,8 +48,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
|||
val page = model.page ?: 0
|
||||
|
||||
var sampleSize = 1
|
||||
val customSize = width > 0 && height > 0
|
||||
if (customSize) {
|
||||
if (width > 0 && height > 0) {
|
||||
// determine sample size
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
|
@ -64,8 +62,8 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
|||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
val imageHeight = options.outHeight
|
||||
if (imageWidth > width || imageHeight > height) {
|
||||
while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) {
|
||||
if (imageHeight > height || imageWidth > width) {
|
||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
@ -83,23 +81,9 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
|||
inSampleSize = sampleSize
|
||||
}
|
||||
try {
|
||||
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
|
||||
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
||||
} else if (customSize) {
|
||||
val dstWidth: Int
|
||||
val dstHeight: Int
|
||||
val aspectRatio = bitmap.width.toFloat() / bitmap.height
|
||||
if (aspectRatio > 1) {
|
||||
dstWidth = (height * aspectRatio).toInt()
|
||||
dstHeight = height
|
||||
} else {
|
||||
dstWidth = width
|
||||
dstHeight = (width / aspectRatio).toInt()
|
||||
}
|
||||
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
|
||||
} else {
|
||||
callback.onDataReady(bitmap)
|
||||
}
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
|
@ -20,62 +16,50 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
|
|||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.MemoryUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
import java.io.InputStream
|
||||
|
||||
@GlideModule
|
||||
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
|
||||
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
|
||||
}
|
||||
}
|
||||
|
||||
class VideoThumbnail(val context: Context, val uri: Uri)
|
||||
|
||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
|
||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
|
||||
}
|
||||
|
||||
override fun handles(model: VideoThumbnail): Boolean = true
|
||||
|
||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
|
||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
}
|
||||
|
||||
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
||||
ioScope.launch {
|
||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||
if (retriever == null) {
|
||||
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
||||
} else {
|
||||
try {
|
||||
var bitmap: Bitmap? = null
|
||||
|
||||
retriever.embeddedPicture?.let { bytes ->
|
||||
try {
|
||||
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
|
||||
} catch (e: IOException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap == null) {
|
||||
var bytes = retriever.embeddedPicture
|
||||
if (bytes == null) {
|
||||
// there is no consistent strategy across devices to match
|
||||
// the thumbnails returned by the content resolver / Media Store
|
||||
// so we derive one in an arbitrary way
|
||||
|
@ -84,71 +68,18 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
|||
if (durationMillis != null) {
|
||||
timeMillis = if (durationMillis < 15000) 0 else 15000
|
||||
}
|
||||
val timeMicros = if (timeMillis != null) timeMillis * 1000 else -1
|
||||
val option = MediaMetadataRetriever.OPTION_CLOSEST_SYNC
|
||||
|
||||
var videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
|
||||
var videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
|
||||
if (videoWidth == null || videoHeight == null) {
|
||||
throw Exception("failed to get video dimensions")
|
||||
}
|
||||
|
||||
var dstWidth = 0
|
||||
var dstHeight = 0
|
||||
if (width > 0 && height > 0) {
|
||||
val rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull()
|
||||
if (rotationDegrees != null) {
|
||||
val isRotated = rotationDegrees % 180 == 90
|
||||
if (isRotated) {
|
||||
val temp = videoWidth
|
||||
videoWidth = videoHeight
|
||||
videoHeight = temp
|
||||
}
|
||||
|
||||
// cover fit
|
||||
val videoAspectRatio = videoWidth / videoHeight
|
||||
if (videoWidth > width || videoHeight > height) {
|
||||
if (width / height.toFloat() > videoAspectRatio) {
|
||||
dstHeight = ceil(videoHeight * width / videoWidth).toInt()
|
||||
dstWidth = (dstHeight * videoAspectRatio).roundToInt()
|
||||
} else {
|
||||
dstWidth = ceil(videoWidth * height / videoHeight).toInt()
|
||||
dstHeight = (dstWidth / videoAspectRatio).roundToInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the returned frame is already rotated according to the video metadata
|
||||
bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
val pixelCount = dstWidth * dstHeight
|
||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight, getBitmapParams())
|
||||
} else {
|
||||
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
||||
}
|
||||
val frame = if (timeMillis != null) {
|
||||
retriever.getFrameAtTime(timeMillis * 1000)
|
||||
} else {
|
||||
val pixelCount = videoWidth * videoHeight
|
||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
retriever.getFrameAtTime(timeMicros, option, getBitmapParams())
|
||||
} else {
|
||||
retriever.getFrameAtTime(timeMicros, option)
|
||||
}
|
||||
retriever.frameAtTime
|
||||
}
|
||||
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
||||
}
|
||||
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
|
||||
if (bytes != null) {
|
||||
callback.onDataReady(ByteArrayInputStream(bytes))
|
||||
} else {
|
||||
callback.onDataReady(bitmap)
|
||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onLoadFailed(e)
|
||||
|
@ -160,30 +91,13 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
|||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
|
||||
val params = MediaMetadataRetriever.BitmapParams()
|
||||
params.preferredConfig = this.getPreferredConfig()
|
||||
return params
|
||||
}
|
||||
|
||||
private fun getPreferredConfig(): Bitmap.Config {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
|
||||
// for wide-gamut and HDR content which does not require alpha blending
|
||||
Bitmap.Config.RGBA_1010102
|
||||
} else {
|
||||
Bitmap.Config.ARGB_8888
|
||||
}
|
||||
}
|
||||
|
||||
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
|
||||
override fun cleanup() {}
|
||||
|
||||
// cannot cancel
|
||||
override fun cancel() {}
|
||||
|
||||
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
||||
|
||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||
}
|
|
@ -1,24 +1,20 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.metadata.Directory
|
||||
import com.drew.metadata.exif.ExifDirectoryBase
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.exif.ExifThumbnailDirectory
|
||||
import com.drew.metadata.exif.GpsDirectory
|
||||
import com.drew.metadata.exif.PanasonicRawIFD0Directory
|
||||
import com.drew.metadata.exif.*
|
||||
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
|
||||
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
|
||||
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToLong
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
object ExifInterfaceHelper {
|
||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||
|
@ -26,7 +22,7 @@ object ExifInterfaceHelper {
|
|||
val GPS_DATE_FORMAT = SimpleDateFormat("yyyy:MM:dd", Locale.ROOT)
|
||||
val GPS_TIME_FORMAT = SimpleDateFormat("HH:mm:ss", Locale.ROOT)
|
||||
|
||||
private const val PRECISION_ERROR_TOLERANCE = 1e-10
|
||||
private const val precisionErrorTolerance = 1e-10
|
||||
|
||||
// ExifInterface always states it has the following attributes
|
||||
// and returns "0" instead of "null" when they are actually missing
|
||||
|
@ -224,7 +220,7 @@ object ExifInterfaceHelper {
|
|||
// initialize metadata-extractor directories that we will fill
|
||||
// by tags converted from the ExifInterface attributes
|
||||
// so that we can rely on metadata-extractor descriptions
|
||||
val dirs = DirType.entries.associateWith { it.createDirectory() }
|
||||
val dirs = DirType.values().associateWith { it.createDirectory() }
|
||||
|
||||
// exclude Exif directory when it only includes image size
|
||||
val isUselessExif = fun(it: Map<String, String>): Boolean {
|
||||
|
@ -312,7 +308,7 @@ object ExifInterfaceHelper {
|
|||
val numerator = 1L
|
||||
val f = numerator / d
|
||||
val denominator = f.roundToLong()
|
||||
if (abs(f - denominator) < PRECISION_ERROR_TOLERANCE) {
|
||||
if (abs(f - denominator) < precisionErrorTolerance) {
|
||||
return Rational(numerator, denominator)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
package deckers.thibault.aves.metadata.xmp
|
||||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.countPropPathArrayItems
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.metadata.XMP.countPropPathArrayItems
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.utils.indexOfBytes
|
||||
import java.io.DataInputStream
|
||||
|
||||
|
@ -16,12 +15,12 @@ class GoogleDeviceContainer {
|
|||
private val offsets: MutableList<Int> = ArrayList()
|
||||
|
||||
fun findItems(xmpMeta: XMPMeta) {
|
||||
val containerDirectoryPath = listOf(GoogleXMP.GDEVICE_CONTAINER_PROP_NAME, GoogleXMP.GDEVICE_CONTAINER_DIRECTORY_PROP_NAME)
|
||||
val containerDirectoryPath = listOf(XMP.GDEVICE_CONTAINER_PROP_NAME, XMP.GDEVICE_CONTAINER_DIRECTORY_PROP_NAME)
|
||||
val count = xmpMeta.countPropPathArrayItems(containerDirectoryPath)
|
||||
for (i in 1 until count + 1) {
|
||||
val mimeType = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, GoogleXMP.GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||
val length = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, GoogleXMP.GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME))?.value?.toLongOrNull()
|
||||
val dataUri = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, GoogleXMP.GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME))?.value
|
||||
val mimeType = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, XMP.GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||
val length = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, XMP.GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME))?.value?.toLongOrNull()
|
||||
val dataUri = xmpMeta.getSafeStructField(containerDirectoryPath + listOf(i, XMP.GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME))?.value
|
||||
if (mimeType != null && length != null && dataUri != null) {
|
||||
items.add(
|
||||
GoogleDeviceContainerItem(
|
|
@ -111,25 +111,20 @@ object MediaMetadataRetrieverHelper {
|
|||
// format
|
||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||
val bitrate = value.toLongOrNull() ?: 0
|
||||
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||
}
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||
if (framerate > 0.0) "$framerate" else null
|
||||
}
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
||||
val dateMillis = value.toLongOrNull() ?: 0
|
||||
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
||||
}
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
||||
when (value.toIntOrNull()) {
|
||||
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
||||
|
@ -137,7 +132,6 @@ object MediaMetadataRetrieverHelper {
|
|||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
||||
when (value.toIntOrNull()) {
|
||||
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
||||
|
@ -147,7 +141,6 @@ object MediaMetadataRetrieverHelper {
|
|||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
||||
when (value.toIntOrNull()) {
|
||||
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
||||
|
@ -161,7 +154,6 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
||||
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
||||
|
||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
||||
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
||||
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
||||
|
@ -176,12 +168,4 @@ object MediaMetadataRetrieverHelper {
|
|||
}?.let { save(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getInteger(key))
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getLong(key))
|
||||
}
|
||||
}
|
|
@ -2,24 +2,18 @@ package deckers.thibault.aves.metadata
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
object Metadata {
|
||||
private val LOG_TAG = LogUtils.createTag<Metadata>()
|
||||
|
||||
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
||||
|
||||
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
||||
|
@ -124,7 +118,7 @@ object Metadata {
|
|||
return date.time + parseSubSecond(subSecond)
|
||||
}
|
||||
|
||||
// Opening some large files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
|
||||
// Opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
|
||||
// so we define an arbitrary threshold to avoid a crash on launch.
|
||||
// It is not clear whether it is because of the file itself or its metadata.
|
||||
private const val FILE_SIZE_MAX = 100 * (1 shl 20) // MB
|
||||
|
@ -138,19 +132,15 @@ object Metadata {
|
|||
private val previewFiles = HashMap<Uri, File>()
|
||||
|
||||
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
||||
// formats known to yield OOM for large files
|
||||
return when (mimeType) {
|
||||
// formats known to yield OOM for large files
|
||||
MimeTypes.DNG,
|
||||
MimeTypes.DNG_ADOBE,
|
||||
MimeTypes.HEIC,
|
||||
MimeTypes.HEIF,
|
||||
MimeTypes.MP4,
|
||||
MimeTypes.PSD_VND,
|
||||
MimeTypes.PSD_X,
|
||||
MimeTypes.TIFF ->
|
||||
MimeTypes.TIFF -> {
|
||||
if (isDangerouslyLarge(sizeBytes)) {
|
||||
Log.d(LOG_TAG, "Dangerously large file with uri=$uri, mimeType=$mimeType, size=$sizeBytes")
|
||||
// make a preview from the beginning of the file,
|
||||
// hoping the metadata is accessible in the copied chunk
|
||||
var previewFile = previewFiles[uri]
|
||||
|
@ -163,18 +153,15 @@ object Metadata {
|
|||
// small enough to be safe as it is
|
||||
uri
|
||||
}
|
||||
|
||||
else ->
|
||||
// *probably* safe
|
||||
uri
|
||||
}
|
||||
// *probably* safe
|
||||
else -> uri
|
||||
}
|
||||
}
|
||||
|
||||
fun createPreviewFile(context: Context, uri: Uri): File {
|
||||
val size = PREVIEW_SIZE
|
||||
Log.d(LOG_TAG, "create preview of size=$size for uri=$uri")
|
||||
return StorageUtils.createTempFile(context).apply {
|
||||
transferFrom(StorageUtils.openInputStream(context, uri), size)
|
||||
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,17 +3,12 @@ package deckers.thibault.aves.metadata
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.metadata.xmp.XMP
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.toByteArray
|
||||
import deckers.thibault.aves.utils.toHex
|
||||
import org.mp4parser.BasicContainer
|
||||
import org.mp4parser.Box
|
||||
import org.mp4parser.Container
|
||||
import org.mp4parser.IsoFile
|
||||
import org.mp4parser.PropertyBoxParserImpl
|
||||
import org.mp4parser.*
|
||||
import org.mp4parser.boxes.UnknownBox
|
||||
import org.mp4parser.boxes.UserBox
|
||||
import org.mp4parser.boxes.apple.AppleCoverBox
|
||||
|
@ -21,18 +16,8 @@ import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
|||
import org.mp4parser.boxes.apple.AppleItemListBox
|
||||
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
||||
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
||||
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
||||
import org.mp4parser.boxes.iso14496.part12.HandlerBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MetaBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MovieBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MovieFragmentBox
|
||||
import org.mp4parser.boxes.iso14496.part12.SampleTableBox
|
||||
import org.mp4parser.boxes.iso14496.part12.SegmentIndexBox
|
||||
import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox
|
||||
import org.mp4parser.boxes.iso14496.part12.UserDataBox
|
||||
import org.mp4parser.boxes.iso14496.part12.*
|
||||
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
||||
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
||||
import org.mp4parser.support.AbstractBox
|
||||
import org.mp4parser.support.Matrix
|
||||
import org.mp4parser.tools.Path
|
||||
|
@ -46,15 +31,6 @@ object Mp4ParserHelper {
|
|||
// arbitrary size to detect boxes that may yield an OOM
|
||||
private const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
||||
|
||||
const val SAMSUNG_MAKERNOTE_BOX_TYPE = "sefd"
|
||||
const val SEFD_MOTION_PHOTO_NAME = "MotionPhoto_Data"
|
||||
|
||||
private val largerTypeWhitelist = listOf(
|
||||
// HEIC motion photo may contain Samsung maker notes in `sefd` box,
|
||||
// including a video larger than the danger threshold
|
||||
SAMSUNG_MAKERNOTE_BOX_TYPE,
|
||||
)
|
||||
|
||||
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
|
@ -143,35 +119,6 @@ object Mp4ParserHelper {
|
|||
return false
|
||||
}
|
||||
|
||||
// returns the offset and data of the Samsung maker notes box
|
||||
fun getSamsungSefd(context: Context, uri: Uri): Pair<Long, ByteArray>? {
|
||||
try {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
pfd.use {
|
||||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
||||
var offset = 0L
|
||||
for (box in isoFile.boxes) {
|
||||
if (box is UnknownBox && box.type == SAMSUNG_MAKERNOTE_BOX_TYPE) {
|
||||
if (!box.isParsed) {
|
||||
box.parseDetails()
|
||||
}
|
||||
return Pair(offset + 8, box.data.toByteArray()) // skip 8 bytes for box header
|
||||
}
|
||||
offset += box.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to read sefd box", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// extensions
|
||||
|
||||
fun IsoFile.updateLocation(locationIso6709: String?) {
|
||||
|
@ -311,18 +258,18 @@ object Mp4ParserHelper {
|
|||
)
|
||||
setBoxSkipper { type, size ->
|
||||
if (skippedTypes.contains(type)) return@setBoxSkipper true
|
||||
if (size > BOX_SIZE_DANGER_THRESHOLD && !largerTypeWhitelist.contains(type)) throw Exception("box (type=$type size=$size) is too large")
|
||||
if (size > BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserDataBox(
|
||||
fun getUserData(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
): UserDataBox? {
|
||||
if (mimeType != MimeTypes.MP4) return null
|
||||
|
||||
): MutableMap<String, String> {
|
||||
val fields = HashMap<String, String>()
|
||||
if (mimeType != MimeTypes.MP4) return fields
|
||||
try {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
|
@ -331,7 +278,10 @@ object Mp4ParserHelper {
|
|||
stream.channel.use { channel ->
|
||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
||||
return Path.getPath(isoFile.movieBox, UserDataBox.TYPE)
|
||||
val userDataBox = Path.getPath<UserDataBox>(isoFile.movieBox, UserDataBox.TYPE)
|
||||
if (userDataBox != null) {
|
||||
fields.putAll(extractBoxFields(userDataBox))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -341,10 +291,10 @@ object Mp4ParserHelper {
|
|||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
return null
|
||||
return fields
|
||||
}
|
||||
|
||||
fun extractBoxFields(container: Container): HashMap<String, String> {
|
||||
private fun extractBoxFields(container: Container): HashMap<String, String> {
|
||||
val fields = HashMap<String, String>()
|
||||
for (box in container.boxes) {
|
||||
if (box is AbstractBox && !box.isParsed) {
|
||||
|
@ -358,20 +308,9 @@ object Mp4ParserHelper {
|
|||
is AppleGPSCoordinatesBox -> fields[key] = box.value
|
||||
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
|
||||
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
|
||||
is HandlerBox -> {}
|
||||
is LocationInformationBox -> {
|
||||
hashMapOf<String, String>(
|
||||
"Language" to box.language,
|
||||
"Name" to box.name,
|
||||
"Role" to box.role.toString(),
|
||||
"Longitude" to box.longitude.toString(),
|
||||
"Latitude" to box.latitude.toString(),
|
||||
"Altitude" to box.altitude.toString(),
|
||||
"Astronomical Body" to box.astronomicalBody,
|
||||
"Additional Notes" to box.additionalNotes,
|
||||
).forEach { (k, v) -> fields["$key/$k"] = v }
|
||||
}
|
||||
is Utf8AppleDataBox -> fields[key] = box.value
|
||||
|
||||
is HandlerBox -> {}
|
||||
is MetaBox -> {
|
||||
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
|
||||
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
|
||||
|
@ -396,8 +335,6 @@ object Mp4ParserHelper {
|
|||
}
|
||||
}
|
||||
|
||||
is Utf8AppleDataBox -> fields[key] = box.value
|
||||
|
||||
else -> fields[key] = box.toString()
|
||||
}
|
||||
}
|
||||
|
@ -410,7 +347,6 @@ object Mp4ParserHelper {
|
|||
"catg" -> "Category"
|
||||
"covr" -> "Cover Art"
|
||||
"keyw" -> "Keyword"
|
||||
"loci" -> "Location"
|
||||
"mcvr" -> "Preview Image"
|
||||
"pcst" -> "Podcast"
|
||||
"SDLN" -> "Play Mode"
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
|
@ -10,35 +8,22 @@ import android.os.Build
|
|||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import com.drew.imaging.jpeg.JpegSegmentType
|
||||
import com.drew.metadata.exif.ExifDirectoryBase
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||
import deckers.thibault.aves.metadata.XMP.countPropArrayItems
|
||||
import deckers.thibault.aves.metadata.XMP.doesPropExist
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLong
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntryDirectory
|
||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||
import deckers.thibault.aves.metadata.xmp.XMP
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.indexOfBytes
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.DataInputStream
|
||||
import java.io.EOFException
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
object MultiPage {
|
||||
private val LOG_TAG = LogUtils.createTag<MultiPage>()
|
||||
|
||||
// TODO TLAD more generic support, (e.g. 0x00000014 + `ftyp` + `qt `)
|
||||
// atom length (variable, e.g. `0x00000018`) + atom type (`ftyp`) + type (variable, e.g. `mp42`, `qt`)
|
||||
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
||||
|
||||
// page info
|
||||
|
@ -51,16 +36,24 @@ object MultiPage {
|
|||
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
||||
|
||||
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getInteger(key))
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getLong(key))
|
||||
}
|
||||
|
||||
val tracks = ArrayList<FieldMap>()
|
||||
val extractor = MediaExtractor()
|
||||
extractor.setDataSource(context, uri, null)
|
||||
for (pageIndex in 0 until extractor.trackCount) {
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
try {
|
||||
val format = extractor.getTrackFormat(pageIndex)
|
||||
val format = extractor.getTrackFormat(i)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
|
||||
val track: FieldMap = hashMapOf(
|
||||
KEY_PAGE to pageIndex,
|
||||
KEY_PAGE to i,
|
||||
KEY_MIME_TYPE to trackMime,
|
||||
)
|
||||
|
||||
|
@ -79,226 +72,78 @@ object MultiPage {
|
|||
tracks.add(track)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, pageIndex=$pageIndex", e)
|
||||
Log.w(LOG_TAG, "failed to get HEIC track information for uri=$uri, track num=$i", e)
|
||||
}
|
||||
}
|
||||
extractor.release()
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun isHeicSefdMotionPhoto(context: Context, uri: Uri): Boolean {
|
||||
return getHeicSefdMotionPhotoVideoSizing(context, uri) != null
|
||||
}
|
||||
|
||||
private fun getHeicSefdMotionPhotoVideoSizing(context: Context, uri: Uri): Pair<Long, Long>? {
|
||||
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (sefdOffset, sefdBytes) ->
|
||||
// we could properly parse each tag until we find the "embedded video" tag (0x0a30)
|
||||
// but it seems that decoding the SEFT trailer is necessary for this,
|
||||
// so we simply search for the "MotionPhoto_Data" sequence instead
|
||||
val name = Mp4ParserHelper.SEFD_MOTION_PHOTO_NAME
|
||||
val index = sefdBytes.indexOfBytes(name.toByteArray(Charsets.UTF_8))
|
||||
if (index != -1) {
|
||||
val videoOffset = sefdOffset + index + name.length
|
||||
val videoSize = sefdBytes.size - (videoOffset - sefdOffset)
|
||||
return Pair(videoOffset, videoSize)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
|
||||
val mimeType = MimeTypes.JPEG
|
||||
var rotationDegrees = 0
|
||||
|
||||
var foundExif = false
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
dir.getSafeInt(ExifDirectoryBase.TAG_ORIENTATION) {
|
||||
rotationDegrees = Metadata.getRotationDegreesForExifCode(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
||||
} catch (e: AssertionError) {
|
||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundExif) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
|
||||
rotationDegrees = exif.rotationDegrees
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
return rotationDegrees
|
||||
}
|
||||
|
||||
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
|
||||
fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
|
||||
val mimeType = MimeTypes.JPEG
|
||||
val endMarker = 0xFF
|
||||
val app2Marker = JpegSegmentType.APP2.byteValue
|
||||
val mpfMarker = "MPF".toByteArray() + 0x00
|
||||
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
var offset = 0
|
||||
val marker = ByteArray(4)
|
||||
while (true) {
|
||||
// look for APP2 marker (0xFFE2)
|
||||
var found = false
|
||||
while (!found) {
|
||||
var i = input.read()
|
||||
if (i == -1) throw EOFException()
|
||||
offset++
|
||||
if (i == endMarker) {
|
||||
i = input.read()
|
||||
if (i == -1) throw EOFException()
|
||||
offset++
|
||||
found = i.toByte() == app2Marker
|
||||
}
|
||||
}
|
||||
// skip 2 bytes for segment size
|
||||
input.skip(2)
|
||||
offset += 2
|
||||
input.read(marker, 0, marker.size)
|
||||
offset += 4
|
||||
if (marker.contentEquals(mpfMarker)) {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get MPF base offset from uri=$uri", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getJpegMpfEntries(context: Context, uri: Uri, sizeBytes: Long?): List<MpEntry>? {
|
||||
val mimeType = MimeTypes.JPEG
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
return metadata.getDirectoriesOfType(MpEntryDirectory::class.java).map { it.entry }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to find MPF entries", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to find MPF entries", e)
|
||||
} catch (e: AssertionError) {
|
||||
Log.w(LOG_TAG, "failed to find MPF entries", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getJpegMpfPages(context: Context, uri: Uri, sizeBytes: Long): ArrayList<FieldMap> {
|
||||
val primaryRotation = getJpegMpfPrimaryRotation(context, uri, sizeBytes)
|
||||
|
||||
val pages = ArrayList<FieldMap>()
|
||||
val baseOffset = getJpegMpfBaseOffset(context, uri, sizeBytes)
|
||||
val mpEntries = getJpegMpfEntries(context, uri, sizeBytes)
|
||||
if (mpEntries != null && baseOffset != null) {
|
||||
for ((pageIndex, mpEntry) in mpEntries.withIndex()) {
|
||||
mpEntry.mimeType?.let { embedMimeType ->
|
||||
val page = hashMapOf<String, Any?>(
|
||||
KEY_PAGE to pageIndex,
|
||||
KEY_MIME_TYPE to embedMimeType,
|
||||
KEY_IS_DEFAULT to (pageIndex == 0),
|
||||
KEY_ROTATION_DEGREES to primaryRotation,
|
||||
)
|
||||
|
||||
var dataOffset = mpEntry.dataOffset
|
||||
if (dataOffset > 0) {
|
||||
dataOffset += baseOffset
|
||||
}
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(dataOffset)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
options.outWidth.takeIf { it >= 0 }?.let { page[KEY_WIDTH] = it }
|
||||
options.outHeight.takeIf { it >= 0 }?.let { page[KEY_HEIGHT] = it }
|
||||
|
||||
pages.add(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
fun getJpegMpfBitmap(context: Context, uri: Uri, pageIndex: Int): Bitmap? {
|
||||
val mpEntries = getJpegMpfEntries(context, uri, null)
|
||||
if (mpEntries != null && pageIndex < mpEntries.size) {
|
||||
val mpEntry = mpEntries[pageIndex]
|
||||
var dataOffset = mpEntry.dataOffset
|
||||
if (dataOffset > 0) {
|
||||
val baseOffset = getJpegMpfBaseOffset(context, uri, null)
|
||||
if (baseOffset != null) {
|
||||
dataOffset += baseOffset
|
||||
}
|
||||
}
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
input.skip(dataOffset)
|
||||
return BitmapFactory.decodeStream(input)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
||||
val pages = ArrayList<FieldMap>()
|
||||
getMotionPhotoVideoInfo(context, uri, mimeType, sizeBytes)?.let { videoInfo ->
|
||||
// set the original image as the first and default track
|
||||
var pageIndex = 0
|
||||
pages.add(
|
||||
hashMapOf(
|
||||
KEY_PAGE to pageIndex++,
|
||||
KEY_MIME_TYPE to mimeType,
|
||||
KEY_IS_DEFAULT to true,
|
||||
)
|
||||
)
|
||||
// add video tracks from the appended video
|
||||
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
if (MimeTypes.isVideo(mime)) {
|
||||
val page: FieldMap = hashMapOf(
|
||||
KEY_PAGE to pageIndex++,
|
||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||
KEY_IS_DEFAULT to false,
|
||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getInteger(key))
|
||||
}
|
||||
|
||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||
if (this.containsKey(key)) save(this.getLong(key))
|
||||
}
|
||||
|
||||
val tracks = ArrayList<FieldMap>()
|
||||
val extractor = MediaExtractor()
|
||||
var pfd: ParcelFileDescriptor? = null
|
||||
try {
|
||||
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
pfd?.fileDescriptor?.let { fd ->
|
||||
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
|
||||
// set the original image as the first and default track
|
||||
var trackCount = 0
|
||||
tracks.add(
|
||||
hashMapOf(
|
||||
KEY_PAGE to trackCount++,
|
||||
KEY_MIME_TYPE to mimeType,
|
||||
KEY_IS_DEFAULT to true,
|
||||
)
|
||||
)
|
||||
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
||||
// add video tracks from the appended video
|
||||
if (extractor.trackCount > 0) {
|
||||
// only consider the first track to represent the appended video
|
||||
val trackIndex = 0
|
||||
try {
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
if (MimeTypes.isVideo(mime)) {
|
||||
val track: FieldMap = hashMapOf(
|
||||
KEY_PAGE to trackCount++,
|
||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||
KEY_IS_DEFAULT to false,
|
||||
)
|
||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
|
||||
}
|
||||
format.getSafeLong(MediaFormat.KEY_DURATION) { track[KEY_DURATION] = it / 1000 }
|
||||
tracks.add(track)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
|
||||
}
|
||||
}
|
||||
videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||
pages.add(page)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
||||
} finally {
|
||||
extractor.release()
|
||||
pfd?.close()
|
||||
}
|
||||
return pages
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun getTrailerVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||
if (MimeTypes.isHeic(mimeType)) {
|
||||
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
||||
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
||||
|
@ -323,12 +168,25 @@ object MultiPage {
|
|||
var foundXmp = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta) {
|
||||
offsetFromEnd = offsetFromEnd ?: GoogleXMP.getTrailingVideoOffsetFromEnd(xmpMeta)
|
||||
if (xmpMeta.doesPropExist(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||
// `GCamera` motion photo
|
||||
xmpMeta.getSafeLong(XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
} else if (xmpMeta.doesPropExist(XMP.GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
// `Container` motion photo
|
||||
val count = xmpMeta.countPropArrayItems(XMP.GCONTAINER_DIRECTORY_PROP_NAME)
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||
val length = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
||||
if (MimeTypes.isVideo(mime) && length != null) {
|
||||
offsetFromEnd = length.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||
}
|
||||
|
@ -345,66 +203,10 @@ object MultiPage {
|
|||
return offsetFromEnd
|
||||
}
|
||||
|
||||
private fun getMotionPhotoVideoInfo(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): MediaFormat? {
|
||||
getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
|
||||
return getEmbedVideoInfo(context, uri, videoOffset, videoSize)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSize: Long, videoSize: Long): MediaFormat? {
|
||||
return getEmbedVideoInfo(context, uri, videoOffset = fileSize - videoSize, videoSize = videoSize)
|
||||
}
|
||||
|
||||
private fun getEmbedVideoInfo(context: Context, uri: Uri, videoOffset: Long, videoSize: Long): MediaFormat? {
|
||||
val extractor = MediaExtractor()
|
||||
var pfd: ParcelFileDescriptor? = null
|
||||
try {
|
||||
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
||||
pfd?.fileDescriptor?.let { fd ->
|
||||
extractor.setDataSource(fd, videoOffset, videoSize)
|
||||
// video track may be after an audio track
|
||||
for (trackIndex in 0 until extractor.trackCount) {
|
||||
try {
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let {
|
||||
if (MimeTypes.isVideo(it)) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$trackIndex", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
||||
} finally {
|
||||
extractor.release()
|
||||
pfd?.close()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getMotionPhotoVideoSizing(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Pair<Long, Long>? {
|
||||
// default to trailer videos
|
||||
getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSize ->
|
||||
val videoOffset = sizeBytes - videoSize
|
||||
return Pair(videoOffset, videoSize)
|
||||
}
|
||||
|
||||
if (MimeTypes.isHeic(mimeType)) {
|
||||
// fallback to video within Samsung SEFD box
|
||||
return getHeicSefdMotionPhotoVideoSizing(context, uri)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
||||
fun toMap(page: Int, options: TiffBitmapFactory.Options): FieldMap {
|
||||
return hashMapOf(
|
||||
KEY_PAGE to pageIndex,
|
||||
KEY_PAGE to page,
|
||||
KEY_MIME_TYPE to MimeTypes.TIFF,
|
||||
KEY_WIDTH to options.outWidth,
|
||||
KEY_HEIGHT to options.outHeight,
|
||||
|
@ -415,8 +217,8 @@ object MultiPage {
|
|||
getTiffPageInfo(context, uri, 0)?.let { first ->
|
||||
pages.add(toMap(0, first))
|
||||
val pageCount = first.outDirectoryCount
|
||||
for (pageIndex in 1 until pageCount) {
|
||||
getTiffPageInfo(context, uri, pageIndex)?.let { pages.add(toMap(pageIndex, it)) }
|
||||
for (i in 1 until pageCount) {
|
||||
getTiffPageInfo(context, uri, i)?.let { pages.add(toMap(i, it)) }
|
||||
}
|
||||
}
|
||||
return pages
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||
|
@ -12,8 +10,6 @@ import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
|||
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import pixy.meta.meta.Metadata
|
||||
import pixy.meta.meta.MetadataEntry
|
||||
import pixy.meta.meta.MetadataType
|
||||
|
@ -23,9 +19,9 @@ import pixy.meta.meta.iptc.IPTCRecord
|
|||
import pixy.meta.meta.jpeg.JPGMeta
|
||||
import pixy.meta.meta.xmp.XMP
|
||||
import pixy.meta.string.XMLUtils
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
|
||||
object PixyMetaHelper {
|
||||
fun describe(input: InputStream): HashMap<String, String> {
|
||||
|
@ -81,18 +77,17 @@ object PixyMetaHelper {
|
|||
output: OutputStream,
|
||||
iptcDataList: List<FieldMap>?,
|
||||
) {
|
||||
val iptc: List<IPTCDataSet> = iptcDataList?.flatMap {
|
||||
val iptc = iptcDataList?.flatMap {
|
||||
val record = it["record"] as Int
|
||||
val tag = it["tag"] as Int
|
||||
val values = it["values"] as List<*>
|
||||
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
||||
} ?: ArrayList()
|
||||
} ?: ArrayList<IPTCDataSet>()
|
||||
Metadata.insertIPTC(input, output, iptc)
|
||||
}
|
||||
|
||||
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
||||
|
||||
// PixyMeta may fail with just a log, and write nothing to the output
|
||||
fun setXmp(
|
||||
input: InputStream,
|
||||
output: OutputStream,
|
||||
|
@ -110,48 +105,6 @@ object PixyMetaHelper {
|
|||
|
||||
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
|
||||
|
||||
fun copyIptcXmp(
|
||||
context: Context,
|
||||
sourceMimeType: String,
|
||||
sourceUri: Uri,
|
||||
targetMimeType: String,
|
||||
targetUri: Uri,
|
||||
editableFile: File,
|
||||
) {
|
||||
var pixyIptc: IPTC? = null
|
||||
var pixyXmp: XMP? = null
|
||||
if (MimeTypes.canReadWithPixyMeta(sourceMimeType)) {
|
||||
StorageUtils.openInputStream(context, sourceUri)?.use { input ->
|
||||
val metadata = Metadata.readMetadata(input)
|
||||
if (MimeTypes.canEditIptc(targetMimeType)) {
|
||||
pixyIptc = metadata[MetadataType.IPTC] as IPTC?
|
||||
}
|
||||
if (MimeTypes.canEditXmp(targetMimeType)) {
|
||||
pixyXmp = metadata[MetadataType.XMP] as XMP?
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pixyIptc != null || pixyXmp != null) {
|
||||
editableFile.outputStream().use { output ->
|
||||
if (pixyIptc != null) {
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, targetUri)?.use { input ->
|
||||
val iptcs = pixyIptc!!.dataSets.flatMap { it.value }
|
||||
Metadata.insertIPTC(input, output, iptcs)
|
||||
}
|
||||
}
|
||||
if (pixyXmp != null) {
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, targetUri)?.use { input ->
|
||||
val xmpString = pixyXmp!!.xmpDocString()
|
||||
val extendedXmp = if (pixyXmp!!.hasExtendedXmp()) pixyXmp!!.extendedXmpDocString() else null
|
||||
setXmp(input, output, xmpString, if (targetMimeType == MimeTypes.JPEG) extendedXmp else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMetadata(input: InputStream, output: OutputStream, metadataTypes: Set<String>) {
|
||||
val types = metadataTypes.map(::toMetadataType).toTypedArray()
|
||||
Metadata.removeMetadata(input, output, *types)
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.metadata
|
|||
import deckers.thibault.aves.utils.toHex
|
||||
import java.math.BigInteger
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
|
||||
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
||||
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import com.caverock.androidsvg.SVG
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.math.max
|
||||
|
||||
object SvgHelper {
|
||||
fun SVG.normalizeSize() {
|
||||
|
@ -13,19 +10,4 @@ object SvgHelper {
|
|||
setDocumentWidth("100%")
|
||||
setDocumentHeight("100%")
|
||||
}
|
||||
}
|
||||
|
||||
// As of AndroidSVG v1.4, SVGParser.ENTITY_WATCH_BUFFER_SIZE is set at 4096.
|
||||
// This constant is not configurable and used for the internal buffer mark read limit.
|
||||
// Parsing will fail if the SVG header is larger than this value.
|
||||
// So we define and apply a minimum read limit.
|
||||
class SVGParserBufferedInputStream(input: InputStream) : BufferedInputStream(input) {
|
||||
@Synchronized
|
||||
override fun mark(readlimit: Int) {
|
||||
super.mark(max(MINIMUM_READ_LIMIT, readlimit))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MINIMUM_READ_LIMIT = 1 shl 14 // 16kB
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package deckers.thibault.aves.metadata.xmp
|
||||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
@ -11,7 +11,6 @@ import com.adobe.internal.xmp.XMPMeta
|
|||
import com.adobe.internal.xmp.XMPMetaFactory
|
||||
import com.adobe.internal.xmp.properties.XMPProperty
|
||||
import com.drew.metadata.Directory
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes
|
||||
import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
|
||||
|
@ -40,8 +39,16 @@ object XMP {
|
|||
private const val XMP_NS_URI = "http://ns.adobe.com/xap/1.0/"
|
||||
|
||||
// other namespaces
|
||||
private const val APPLE_HDRGM_NS_URI = "http://ns.apple.com/HDRGainMap/1.0/"
|
||||
private const val HDRGM_NS_URI = "http://ns.adobe.com/hdr-gain-map/1.0/"
|
||||
private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/"
|
||||
private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/"
|
||||
private const val GCONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/"
|
||||
private const val GCONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/"
|
||||
private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/"
|
||||
private const val GDEVICE_NS_URI = "http://ns.google.com/photos/dd/1.0/device/"
|
||||
private const val GDEVICE_CONTAINER_NS_URI = "http://ns.google.com/photos/dd/1.0/container/"
|
||||
private const val GDEVICE_ITEM_NS_URI = "http://ns.google.com/photos/dd/1.0/item/"
|
||||
private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/"
|
||||
private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/"
|
||||
private const val PMTM_NS_URI = "http://www.hdrsoft.com/photomatix_settings01"
|
||||
|
||||
val DC_SUBJECT_PROP_NAME = XMPPropName(DC_NS_URI, "subject")
|
||||
|
@ -55,17 +62,59 @@ object XMP {
|
|||
private const val GENERIC_LANG = ""
|
||||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
fun isDataPath(path: String) = GoogleXMP.isDataPath(path)
|
||||
// embedded media data properties
|
||||
// cf https://developers.google.com/depthmap-metadata
|
||||
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
|
||||
private val knownDataProps = listOf(
|
||||
XMPPropName(GAUDIO_NS_URI, "Data"),
|
||||
XMPPropName(GCAMERA_NS_URI, "RelitInputImageData"),
|
||||
XMPPropName(GIMAGE_NS_URI, "Data"),
|
||||
XMPPropName(GDEPTH_NS_URI, "Data"),
|
||||
XMPPropName(GDEPTH_NS_URI, "Confidence"),
|
||||
)
|
||||
|
||||
// HDR gain map
|
||||
fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it }
|
||||
|
||||
private val HDRGM_VERSION_PROP_NAME = XMPPropName(HDRGM_NS_URI, "Version")
|
||||
private val APPLE_HDRGM_VERSION_PROP_NAME = XMPPropName(APPLE_HDRGM_NS_URI, "HDRGainMapVersion")
|
||||
// google portrait
|
||||
|
||||
val GDEVICE_CONTAINER_PROP_NAME = XMPPropName(GDEVICE_NS_URI, "Container")
|
||||
val GDEVICE_CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GDEVICE_CONTAINER_NS_URI, "Directory")
|
||||
val GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "DataURI")
|
||||
val GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Length")
|
||||
val GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Mime")
|
||||
|
||||
// motion photo
|
||||
|
||||
val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
|
||||
val GCONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Directory")
|
||||
val GCONTAINER_ITEM_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Item")
|
||||
val GCONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Length")
|
||||
val GCONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Mime")
|
||||
|
||||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
||||
val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels")
|
||||
val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels")
|
||||
val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels")
|
||||
val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels")
|
||||
val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels")
|
||||
val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels")
|
||||
val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType")
|
||||
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
|
||||
|
||||
private val PMTM_IS_PANO360_PROP_NAME = XMPPropName(PMTM_NS_URI, "IsPano360")
|
||||
|
||||
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
|
||||
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
|
||||
private val gpanoRequiredProps = listOf(
|
||||
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
||||
)
|
||||
|
||||
// as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
|
||||
// so we fall back to the native content resolver, if possible
|
||||
fun checkHeic(
|
||||
|
@ -131,33 +180,47 @@ object XMP {
|
|||
|
||||
// extensions
|
||||
|
||||
fun XMPMeta.hasHdrGainMap(): Boolean {
|
||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||
try {
|
||||
// standard HDR gain map
|
||||
if (doesPropExist(HDRGM_VERSION_PROP_NAME)) return true
|
||||
// GCamera motion photo
|
||||
if (doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
||||
|
||||
// `Ultra HDR`
|
||||
if (GoogleXMP.isUltraHdPhoto(this)) return true
|
||||
|
||||
// Apple HDR gain map
|
||||
if (doesPropExist(APPLE_HDRGM_VERSION_PROP_NAME)) return true
|
||||
// Container motion photo
|
||||
if (doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
val count = countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||
var hasImage = false
|
||||
var hasVideo = false
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||
val length = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
||||
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||
}
|
||||
if (hasImage && hasVideo) return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check HDR props from XMP", e)
|
||||
Log.w(LOG_TAG, "failed to check Google motion photo props from XMP", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun XMPMeta.isMotionPhoto() = GoogleXMP.isMotionPhoto(this)
|
||||
|
||||
fun XMPMeta.isPanorama(): Boolean {
|
||||
// Google
|
||||
if (GoogleXMP.isPanorama(this)) return true
|
||||
try {
|
||||
if (gpanoRequiredProps.all { doesPropExist(it) }) return true
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Google panorama props from XMP", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Photomatix
|
||||
try {
|
|
@ -27,7 +27,6 @@ import com.drew.metadata.xmp.XmpReader
|
|||
import deckers.thibault.aves.metadata.ExifGeoTiffTags
|
||||
import deckers.thibault.aves.metadata.GeoTiffKeys
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpfReader
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.IOException
|
||||
|
@ -59,21 +58,19 @@ object Helper {
|
|||
// e.g. "exif [...] 134 [...] 4578696600004949[...]"
|
||||
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
// providing the stream length is risky, as it may crash if it is incorrect
|
||||
private const val safeReadStreamLength = -1L
|
||||
|
||||
fun readMimeType(input: InputStream): String? {
|
||||
val bufferedInputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
||||
return FileTypeDetector.detectFileType(bufferedInputStream).mimeType
|
||||
}
|
||||
|
||||
@Throws(IOException::class, ImageProcessingException::class)
|
||||
fun safeRead(input: InputStream, @Suppress("unused_parameter") sizeBytes: Long?): com.drew.metadata.Metadata {
|
||||
fun safeRead(input: InputStream): com.drew.metadata.Metadata {
|
||||
val inputStream = if (input is BufferedInputStream) input else BufferedInputStream(input)
|
||||
val fileType = FileTypeDetector.detectFileType(inputStream)
|
||||
|
||||
// Providing the stream length is risky, as it may crash if it is incorrect.
|
||||
// Not providing the stream length is also risky, as it may lead to OOM
|
||||
// when `RandomAccessStreamReader` reads the entire stream to validate offsets.
|
||||
val undefinedStreamLength = -1L
|
||||
|
||||
val metadata = when (fileType) {
|
||||
FileType.Jpeg -> safeReadJpeg(inputStream)
|
||||
FileType.Mp4 -> safeReadMp4(inputStream)
|
||||
|
@ -84,9 +81,9 @@ object Helper {
|
|||
FileType.Cr2,
|
||||
FileType.Nef,
|
||||
FileType.Orf,
|
||||
FileType.Rw2 -> safeReadTiff(inputStream, undefinedStreamLength)
|
||||
FileType.Rw2 -> safeReadTiff(inputStream)
|
||||
|
||||
else -> ImageMetadataReader.readMetadata(inputStream, undefinedStreamLength, fileType)
|
||||
else -> ImageMetadataReader.readMetadata(inputStream, safeReadStreamLength, fileType)
|
||||
}
|
||||
|
||||
metadata.addDirectory(FileTypeDirectory(fileType))
|
||||
|
@ -100,7 +97,6 @@ object Helper {
|
|||
val readers = ArrayList<JpegSegmentMetadataReader>().apply {
|
||||
addAll(JpegMetadataReader.ALL_READERS.filter { it !is XmpReader })
|
||||
add(SafeXmpReader())
|
||||
add(MpfReader())
|
||||
}
|
||||
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
|
@ -117,8 +113,8 @@ object Helper {
|
|||
}
|
||||
|
||||
@Throws(IOException::class, TiffProcessingException::class)
|
||||
fun safeReadTiff(input: InputStream, streamLength: Long): com.drew.metadata.Metadata {
|
||||
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, streamLength)
|
||||
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
|
||||
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
val handler = SafeExifTiffHandler(metadata, null, 0)
|
||||
TiffReader().processTiff(reader, handler, 0)
|
||||
|
@ -168,7 +164,7 @@ object Helper {
|
|||
|
||||
// This seems to cover all known Exif and Xmp date strings
|
||||
// Note that " : : : : " is a valid date string according to the Exif spec (which means 'unknown date'): http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/datetimeoriginal.html
|
||||
private val dateFormats = arrayOf(
|
||||
private val datePatterns = arrayOf(
|
||||
"yyyy:MM:dd HH:mm:ss",
|
||||
"yyyy:MM:dd HH:mm",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
|
@ -181,7 +177,7 @@ object Helper {
|
|||
"yyyy-MM",
|
||||
"yyyyMMdd", // as used in IPTC data
|
||||
"yyyy"
|
||||
).map { SimpleDateFormat(it, Locale.ROOT) }.toTypedArray()
|
||||
)
|
||||
private val subsecondPattern = Pattern.compile("(\\d\\d:\\d\\d:\\d\\d)(\\.\\d+)")
|
||||
private val timeZonePattern = Pattern.compile("(Z|[+-]\\d\\d:\\d\\d|[+-]\\d\\d\\d\\d)$")
|
||||
private val calendar: Calendar = GregorianCalendar()
|
||||
|
@ -212,10 +208,11 @@ object Helper {
|
|||
effectiveTimeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), ""))
|
||||
dateString = timeZoneMatcher.replaceAll("")
|
||||
}
|
||||
for (dateFormat in dateFormats) {
|
||||
for (datePattern in datePatterns) {
|
||||
try {
|
||||
dateFormat.timeZone = effectiveTimeZone ?: TimeZone.getTimeZone("GMT") // don't interpret zone time
|
||||
val parsed = dateFormat.parse(dateString)
|
||||
val parsed = SimpleDateFormat(datePattern, Locale.ROOT).apply {
|
||||
this.timeZone = effectiveTimeZone ?: TimeZone.getTimeZone("GMT") // don't interpret zone time
|
||||
}.parse(dateString)
|
||||
if (parsed != null) {
|
||||
calendar.time = parsed
|
||||
if (calendar.get(Calendar.YEAR) < PARSED_DATE_YEAR_MAX) {
|
||||
|
@ -296,7 +293,9 @@ object Helper {
|
|||
if (!modelTiePoints && !modelTransformation) return false
|
||||
|
||||
val modelPixelScale = this.containsTag(ExifGeoTiffTags.TAG_MODEL_PIXEL_SCALE)
|
||||
return !((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiePoints))
|
||||
if ((modelTransformation && modelPixelScale) || (modelPixelScale && !modelTiePoints)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO TLAD use `GeoTiffDirectory` from the Java version of `metadata-extractor` when available
|
||||
|
|
|
@ -4,7 +4,7 @@ import com.drew.imaging.mp4.Mp4Handler
|
|||
import com.drew.metadata.Metadata
|
||||
import com.drew.metadata.mp4.Mp4Context
|
||||
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
|
||||
import deckers.thibault.aves.metadata.xmp.XMP
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
|
||||
class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) {
|
||||
override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> {
|
||||
|
|
|
@ -31,7 +31,6 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Locale
|
||||
import java.util.zip.InflaterInputStream
|
||||
import java.util.zip.ZipException
|
||||
|
||||
|
@ -43,7 +42,7 @@ object SafePngMetadataReader {
|
|||
private val LOG_TAG = LogUtils.createTag<SafePngMetadataReader>()
|
||||
|
||||
// arbitrary size to detect chunks that may yield an OOM
|
||||
private const val CHUNK_SIZE_DANGER_THRESHOLD = SafeXmpReader.SEGMENT_TYPE_SIZE_DANGER_THRESHOLD
|
||||
private const val chunkSizeDangerThreshold = SafeXmpReader.SEGMENT_TYPE_SIZE_DANGER_THRESHOLD
|
||||
|
||||
private val latin1Encoding = Charsets.ISO_8859_1
|
||||
private val utf8Encoding = Charsets.UTF_8
|
||||
|
@ -86,7 +85,7 @@ object SafePngMetadataReader {
|
|||
val bytes = chunk.bytes
|
||||
|
||||
// TLAD insert start
|
||||
if (bytes.size > CHUNK_SIZE_DANGER_THRESHOLD) {
|
||||
if (bytes.size > chunkSizeDangerThreshold) {
|
||||
Log.w(LOG_TAG, "PNG chunk $chunkType is too large, with a size of ${bytes.size} B")
|
||||
return
|
||||
}
|
||||
|
@ -160,7 +159,7 @@ object SafePngMetadataReader {
|
|||
// Only compression method allowed by the spec is zero: deflate
|
||||
if (compressionMethod.toInt() == 0) {
|
||||
// bytes left for compressed text is:
|
||||
// total bytes length - (profileNameBytes length + null byte + compression method byte)
|
||||
// total bytes length - (profilenamebytes length + null byte + compression method byte)
|
||||
val bytesLeft = bytes.size - (profileNameBytes.size + 1 + 1)
|
||||
val compressedProfile = reader.getBytes(bytesLeft)
|
||||
try {
|
||||
|
@ -291,12 +290,11 @@ object SafePngMetadataReader {
|
|||
val second = reader.uInt8.toInt()
|
||||
val directory = PngDirectory(PngChunkType.tIME)
|
||||
if (DateUtil.isValidDate(year, month - 1, day) && DateUtil.isValidTime(hour, minute, second)) {
|
||||
val dateString = String.format(Locale.ROOT, "%04d:%02d:%02d %02d:%02d:%02d", year, month, day, hour, minute, second)
|
||||
val dateString = String.format("%04d:%02d:%02d %02d:%02d:%02d", year, month, day, hour, minute, second)
|
||||
directory.setString(PngDirectory.TAG_LAST_MODIFICATION_TIME, dateString)
|
||||
} else {
|
||||
directory.addError(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"PNG tIME data describes an invalid date/time: year=%d month=%d day=%d hour=%d minute=%d second=%d",
|
||||
year, month, day, hour, minute, second
|
||||
)
|
||||
|
|
|
@ -16,7 +16,6 @@ import com.drew.metadata.xmp.XmpDirectory
|
|||
import com.drew.metadata.xmp.XmpReader
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
class SafeXmpReader : XmpReader() {
|
||||
// adapted from `XmpReader` to detect and skip large extended XMP
|
||||
|
@ -134,7 +133,7 @@ class SafeXmpReader : XmpReader() {
|
|||
System.arraycopy(segmentBytes, totalOffset, extendedXMPBuffer, chunkOffset, segmentLength - totalOffset)
|
||||
} else {
|
||||
val directory = XmpDirectory()
|
||||
directory.addError(String.format(Locale.ROOT, "Inconsistent length for the Extended XMP buffer: %d instead of %d", fullLength, extendedXMPBuffer.size))
|
||||
directory.addError(String.format("Inconsistent length for the Extended XMP buffer: %d instead of %d", fullLength, extendedXMPBuffer.size))
|
||||
metadata.addDirectory(directory)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
package deckers.thibault.aves.metadata.metadataextractor.mpf
|
||||
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
||||
class MpEntry(val flags: Int, val format: Int, val type: Int, val size: Long, val dataOffset: Long, val dep1: Short, val dep2: Short) {
|
||||
val mimeType: String?
|
||||
get() = getMimeType(format)
|
||||
|
||||
val isThumbnail: Boolean
|
||||
get() = when (type) {
|
||||
TYPE_THUMBNAIL_VGA, TYPE_THUMBNAIL_FULL_HD -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun toString(): String = "MpEntry#${hashCode()}{flags=$flags, format=$format, type=$type, size=$size, dataOffset=$dataOffset, dep1=$dep1, dep2=$dep2}"
|
||||
|
||||
companion object {
|
||||
const val FLAG_REPRESENTATIVE = 1 shl 2
|
||||
const val FLAG_DEPENDENT_CHILD = 1 shl 3
|
||||
const val FLAG_DEPENDENT_PARENT = 1 shl 4
|
||||
|
||||
const val TYPE_PRIMARY = 0x030000
|
||||
const val TYPE_THUMBNAIL_VGA = 0x010001
|
||||
const val TYPE_THUMBNAIL_FULL_HD = 0x010002
|
||||
const val TYPE_PANORAMA = 0x020001
|
||||
const val TYPE_DISPARITY = 0x020002
|
||||
const val TYPE_MULTI_ANGLE = 0x020003
|
||||
const val TYPE_UNDEFINED = 0x000000
|
||||
|
||||
fun getMimeType(format: Int): String? {
|
||||
return when (format) {
|
||||
0 -> MimeTypes.JPEG
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
package deckers.thibault.aves.metadata.metadataextractor.mpf
|
||||
|
||||
import com.drew.metadata.Directory
|
||||
import com.drew.metadata.TagDescriptor
|
||||
|
||||
class MpEntryDirectory(val id: Int, val entry: MpEntry) : Directory() {
|
||||
private val descriptor = MpEntryDescriptor(this)
|
||||
|
||||
init {
|
||||
setDescriptor(descriptor)
|
||||
}
|
||||
|
||||
fun describe(): Map<String, String> {
|
||||
return HashMap<String, String>().apply {
|
||||
put("Flags", descriptor.getFlagsDescription(entry.flags))
|
||||
put("Format", descriptor.getFormatDescription(entry.format))
|
||||
put("Type", descriptor.getTypeDescription(entry.type))
|
||||
put("Size", "${entry.size} bytes")
|
||||
put("Offset", "${entry.dataOffset} bytes")
|
||||
put("Dependent Image 1 Entry Number", "${entry.dep1}")
|
||||
put("Dependent Image 2 Entry Number", "${entry.dep2}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "MPF Image #$id"
|
||||
}
|
||||
|
||||
override fun getTagNameMap(): HashMap<Int, String> {
|
||||
return _tagNameMap
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val _tagNameMap = HashMap<Int, String>()
|
||||
}
|
||||
}
|
||||
|
||||
class MpEntryDescriptor(directory: MpEntryDirectory?) : TagDescriptor<MpEntryDirectory>(directory) {
|
||||
fun getFlagsDescription(flags: Int): String {
|
||||
val flagStrings = ArrayList<String>().apply {
|
||||
if (flags and MpEntry.FLAG_REPRESENTATIVE != 0) add("representative image")
|
||||
if (flags and MpEntry.FLAG_DEPENDENT_CHILD != 0) add("dependent child image")
|
||||
if (flags and MpEntry.FLAG_DEPENDENT_PARENT != 0) add("dependent parent image")
|
||||
}
|
||||
return if (flagStrings.isEmpty()) "none" else flagStrings.joinToString(", ")
|
||||
}
|
||||
|
||||
fun getFormatDescription(format: Int): String {
|
||||
return MpEntry.getMimeType(format) ?: "Unknown ($format)"
|
||||
}
|
||||
|
||||
fun getTypeDescription(type: Int): String {
|
||||
return when (type) {
|
||||
MpEntry.TYPE_PRIMARY -> "Baseline MP Primary Image"
|
||||
MpEntry.TYPE_THUMBNAIL_VGA -> "Large Thumbnail (VGA equivalent)"
|
||||
MpEntry.TYPE_THUMBNAIL_FULL_HD -> "Large Thumbnail (full HD equivalent)"
|
||||
MpEntry.TYPE_PANORAMA -> "Multi-frame Panorama"
|
||||
MpEntry.TYPE_DISPARITY -> "Multi-frame Disparity"
|
||||
MpEntry.TYPE_MULTI_ANGLE -> "Multi-angle"
|
||||
MpEntry.TYPE_UNDEFINED -> "Undefined"
|
||||
else -> "Unknown ($type)"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
package deckers.thibault.aves.metadata.metadataextractor.mpf
|
||||
|
||||
import com.drew.metadata.Directory
|
||||
import com.drew.metadata.TagDescriptor
|
||||
|
||||
class MpfDirectory : Directory() {
|
||||
init {
|
||||
setDescriptor(MpfDescriptor(this))
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "MPF"
|
||||
}
|
||||
|
||||
override fun getTagNameMap(): HashMap<Int, String> {
|
||||
return _tagNameMap
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG_MPF_VERSION = 0xb000
|
||||
const val TAG_NUMBER_OF_IMAGES = 0xb001
|
||||
const val TAG_MP_ENTRY = 0xb002
|
||||
private const val TAG_IMAGE_UID_LIST = 0xb003
|
||||
private const val TAG_TOTAL_FRAMES = 0xb004
|
||||
|
||||
private val _tagNameMap = HashMap<Int, String>().apply {
|
||||
put(TAG_MPF_VERSION, "MPF Version")
|
||||
put(TAG_NUMBER_OF_IMAGES, "Number Of Images")
|
||||
put(TAG_MP_ENTRY, "MP Entry")
|
||||
put(TAG_IMAGE_UID_LIST, "Image UID List")
|
||||
put(TAG_TOTAL_FRAMES, "Total Frames")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MpfDescriptor(directory: MpfDirectory?) : TagDescriptor<MpfDirectory>(directory)
|
|
@ -1,95 +0,0 @@
|
|||
package deckers.thibault.aves.metadata.metadataextractor.mpf
|
||||
|
||||
import android.util.Log
|
||||
import com.drew.imaging.jpeg.JpegSegmentMetadataReader
|
||||
import com.drew.imaging.jpeg.JpegSegmentType
|
||||
import com.drew.lang.ByteArrayReader
|
||||
import com.drew.lang.RandomAccessReader
|
||||
import com.drew.metadata.Metadata
|
||||
import com.drew.metadata.MetadataReader
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
||||
class MpfReader : JpegSegmentMetadataReader, MetadataReader {
|
||||
override fun getSegmentTypes(): Iterable<JpegSegmentType> {
|
||||
return listOf(JpegSegmentType.APP2)
|
||||
}
|
||||
|
||||
override fun readJpegSegments(segments: Iterable<ByteArray>, metadata: Metadata, segmentType: JpegSegmentType) {
|
||||
for (segmentBytes in segments) {
|
||||
// Skip segments not starting with the required header
|
||||
if (segmentBytes.size >= PREAMBLE.length && PREAMBLE == String(segmentBytes, 0, PREAMBLE.length)) {
|
||||
extract(ByteArrayReader(segmentBytes), metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun extract(reader: RandomAccessReader, metadata: Metadata) {
|
||||
val directory = MpfDirectory()
|
||||
metadata.addDirectory(directory)
|
||||
|
||||
val baseOffset = 4
|
||||
|
||||
// MP Format Identifier (4Byte)
|
||||
// MP header
|
||||
// - MP Endian (4Byte)
|
||||
val byteOrderIdentifier = reader.getInt16(baseOffset)
|
||||
if (byteOrderIdentifier.toInt() == 0x4d4d) { // "MM"
|
||||
reader.isMotorolaByteOrder = true
|
||||
} else if (byteOrderIdentifier.toInt() == 0x4949) { // "II"
|
||||
reader.isMotorolaByteOrder = false
|
||||
}
|
||||
// - Offset to First IFD (4Byte)
|
||||
val firstIfdOffset = reader.getInt32(baseOffset + 4)
|
||||
|
||||
// [in primary image only] MP Index IFD:
|
||||
// - Count (2Byte)
|
||||
var offset = baseOffset + firstIfdOffset
|
||||
val tagCount = reader.getInt16(offset)
|
||||
offset += 2
|
||||
// - MP Index Fields (Overall Structure Info.)
|
||||
var imageCount = 0
|
||||
for (tag in 0..<tagCount) {
|
||||
when (val tagId = reader.getUInt16(offset)) {
|
||||
MpfDirectory.TAG_MPF_VERSION -> directory.setString(tagId, reader.getString(offset + 8, 4, Charsets.US_ASCII))
|
||||
MpfDirectory.TAG_NUMBER_OF_IMAGES -> {
|
||||
imageCount = reader.getInt32(offset + 8)
|
||||
directory.setInt(tagId, imageCount)
|
||||
}
|
||||
|
||||
MpfDirectory.TAG_MP_ENTRY -> {
|
||||
var mpEntryOffset = baseOffset + reader.getInt32(offset + 8)
|
||||
for (index in 0..<imageCount) {
|
||||
// individual image
|
||||
val attribute = reader.getUInt32(mpEntryOffset)
|
||||
val flags = (attribute shr 27 and 0x1f).toInt()
|
||||
val format = (attribute shr 24 and 0x7).toInt()
|
||||
val type = (attribute and 0xffffff).toInt()
|
||||
val size = reader.getUInt32(mpEntryOffset + 4)
|
||||
val dataOffset = reader.getUInt32(mpEntryOffset + 8)
|
||||
val dep1 = reader.getInt16(mpEntryOffset + 12)
|
||||
val dep2 = reader.getInt16(mpEntryOffset + 14)
|
||||
metadata.addDirectory(MpEntryDirectory(index + 1, MpEntry(flags, format, type, size, dataOffset, dep1, dep2)))
|
||||
mpEntryOffset += 16
|
||||
}
|
||||
}
|
||||
|
||||
else -> Log.d(LOG_TAG, "unknown tag=$tagId")
|
||||
}
|
||||
offset += 12
|
||||
}
|
||||
|
||||
// - Offset of Next IFD (4Byte)
|
||||
// Value (MP Index IFD)
|
||||
|
||||
// [in primary & other images] MP Attributes IFD:
|
||||
// - Count (2Byte)
|
||||
// - MP Attribute Fields (Details of Specific Image Usage)
|
||||
// - Offset of Next IFD
|
||||
// Value (MP Attribute IFD)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MpfReader>()
|
||||
private const val PREAMBLE = "MPF"
|
||||
}
|
||||
}
|
|
@ -1,205 +0,0 @@
|
|||
package deckers.thibault.aves.metadata.xmp
|
||||
|
||||
import android.util.Log
|
||||
import com.adobe.internal.xmp.XMPError
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.countPropArrayItems
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.doesPropExist
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.doesPropPathExist
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeInt
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeLong
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeString
|
||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
||||
object GoogleXMP {
|
||||
private val LOG_TAG = LogUtils.createTag<GoogleXMP>()
|
||||
|
||||
// namespaces
|
||||
private const val GAUDIO_NS_URI = "http://ns.google.com/photos/1.0/audio/"
|
||||
private const val GCAMERA_NS_URI = "http://ns.google.com/photos/1.0/camera/"
|
||||
private const val GCONTAINER_NS_URI = "http://ns.google.com/photos/1.0/container/"
|
||||
private const val GCONTAINER_ITEM_NS_URI = "http://ns.google.com/photos/1.0/container/item/"
|
||||
private const val GDEPTH_NS_URI = "http://ns.google.com/photos/1.0/depthmap/"
|
||||
private const val GDEVICE_NS_URI = "http://ns.google.com/photos/dd/1.0/device/"
|
||||
private const val GDEVICE_CONTAINER_NS_URI = "http://ns.google.com/photos/dd/1.0/container/"
|
||||
private const val GDEVICE_ITEM_NS_URI = "http://ns.google.com/photos/dd/1.0/item/"
|
||||
private const val GIMAGE_NS_URI = "http://ns.google.com/photos/1.0/image/"
|
||||
private const val GPANO_NS_URI = "http://ns.google.com/photos/1.0/panorama/"
|
||||
|
||||
// embedded media data properties
|
||||
// cf https://developers.google.com/depthmap-metadata
|
||||
// cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format
|
||||
private val knownDataProps = listOf(
|
||||
XMPPropName(GAUDIO_NS_URI, "Data"),
|
||||
XMPPropName(GCAMERA_NS_URI, "RelitInputImageData"),
|
||||
XMPPropName(GIMAGE_NS_URI, "Data"),
|
||||
XMPPropName(GDEPTH_NS_URI, "Data"),
|
||||
XMPPropName(GDEPTH_NS_URI, "Confidence"),
|
||||
)
|
||||
|
||||
|
||||
fun isDataPath(path: String) = knownDataProps.map { it.toString() }.any { path == it }
|
||||
|
||||
// google portrait
|
||||
|
||||
val GDEVICE_CONTAINER_PROP_NAME = XMPPropName(GDEVICE_NS_URI, "Container")
|
||||
val GDEVICE_CONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GDEVICE_CONTAINER_NS_URI, "Directory")
|
||||
val GDEVICE_CONTAINER_ITEM_DATA_URI_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "DataURI")
|
||||
val GDEVICE_CONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Length")
|
||||
val GDEVICE_CONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GDEVICE_ITEM_NS_URI, "Mime")
|
||||
|
||||
// container
|
||||
|
||||
private val GCAMERA_VIDEO_OFFSET_PROP_NAME = XMPPropName(GCAMERA_NS_URI, "MicroVideoOffset")
|
||||
private val GCONTAINER_DIRECTORY_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Directory")
|
||||
private val GCONTAINER_ITEM_PROP_NAME = XMPPropName(GCONTAINER_NS_URI, "Item")
|
||||
private val GCONTAINER_ITEM_LENGTH_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Length")
|
||||
private val GCONTAINER_ITEM_MIME_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Mime")
|
||||
private val GCONTAINER_ITEM_SEMANTIC_PROP_NAME = XMPPropName(GCONTAINER_ITEM_NS_URI, "Semantic")
|
||||
|
||||
private const val ITEM_SEMANTIC_GAIN_MAP = "GainMap"
|
||||
|
||||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
||||
private val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageHeightPixels")
|
||||
private val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaImageWidthPixels")
|
||||
private val GPANO_CROPPED_AREA_LEFT_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaLeftPixels")
|
||||
private val GPANO_CROPPED_AREA_TOP_PROP_NAME = XMPPropName(GPANO_NS_URI, "CroppedAreaTopPixels")
|
||||
private val GPANO_FULL_PANO_HEIGHT_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoHeightPixels")
|
||||
private val GPANO_FULL_PANO_WIDTH_PROP_NAME = XMPPropName(GPANO_NS_URI, "FullPanoWidthPixels")
|
||||
private val GPANO_PROJECTION_TYPE_PROP_NAME = XMPPropName(GPANO_NS_URI, "ProjectionType")
|
||||
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
|
||||
|
||||
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
|
||||
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
|
||||
private val gpanoRequiredProps = listOf(
|
||||
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
||||
)
|
||||
|
||||
fun isUltraHdPhoto(meta: XMPMeta): Boolean {
|
||||
if (meta.doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
val count = meta.countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||
for (i in 1 until count + 1) {
|
||||
val semantic = meta.getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_SEMANTIC_PROP_NAME))?.value
|
||||
if (semantic == ITEM_SEMANTIC_GAIN_MAP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isMotionPhoto(meta: XMPMeta): Boolean {
|
||||
try {
|
||||
// GCamera motion photo
|
||||
if (meta.doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
||||
|
||||
// Container motion photo
|
||||
if (meta.doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
val count = meta.countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||
var hasImage = false
|
||||
var hasVideo = false
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = getContainerItemAttribute(meta, i, GCONTAINER_ITEM_MIME_PROP_NAME)
|
||||
val length = getContainerItemAttribute(meta, i, GCONTAINER_ITEM_LENGTH_PROP_NAME)
|
||||
// `length` is not always provided for the image item
|
||||
hasImage = hasImage || MimeTypes.isImage(mime)
|
||||
hasVideo = hasVideo || (MimeTypes.isVideo(mime) && length != null)
|
||||
}
|
||||
if (hasImage && hasVideo) return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Google motion photo props from XMP", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getContainerItemAttribute(meta: XMPMeta, i: Int, attribute: XMPPropName): String? {
|
||||
// variant of `Container:Item` with `<rdf:li rdf:parseType="Resource">`
|
||||
val mime = meta.getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, attribute))?.value
|
||||
// variant of `Container:Item` with `<rdf:li>`
|
||||
return mime ?: meta.getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, attribute))?.value
|
||||
}
|
||||
|
||||
fun isPanorama(meta: XMPMeta): Boolean {
|
||||
try {
|
||||
if (gpanoRequiredProps.all { meta.doesPropExist(it) }) return true
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||
Log.w(LOG_TAG, "failed to check Google panorama props from XMP", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getPanoramaInfo(meta: XMPMeta): FieldMap {
|
||||
val fields: FieldMap = hashMapOf()
|
||||
try {
|
||||
meta.getSafeInt(GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
|
||||
meta.getSafeInt(GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
|
||||
meta.getSafeInt(GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
|
||||
meta.getSafeInt(GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
|
||||
meta.getSafeInt(GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
|
||||
meta.getSafeInt(GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
|
||||
meta.getSafeString(GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory", e)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
fun getTrailingVideoOffsetFromEnd(meta: XMPMeta): Long? {
|
||||
var offsetFromEnd: Long? = null
|
||||
if (meta.doesPropExist(GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||
// `GCamera` motion photo
|
||||
meta.getSafeLong(GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
} else if (meta.doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
// `Container` motion photo
|
||||
val count = meta.countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = getContainerItemAttribute(meta, i, GCONTAINER_ITEM_MIME_PROP_NAME)
|
||||
if (MimeTypes.isVideo(mime)) {
|
||||
getContainerItemAttribute(meta, i, GCONTAINER_ITEM_LENGTH_PROP_NAME)?.let { offsetFromEnd = it.toLong() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return offsetFromEnd
|
||||
}
|
||||
|
||||
fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
|
||||
return xmp.replace(
|
||||
// GCamera motion photo
|
||||
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
||||
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newOffset\"",
|
||||
).replace(
|
||||
// Container motion photo
|
||||
"${GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$oldOffset\"",
|
||||
"${GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newOffset\"",
|
||||
)
|
||||
}
|
||||
|
||||
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
||||
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
||||
GoogleDeviceContainer().apply { findItems(meta) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,30 +1,23 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class AvesEntry(map: FieldMap) {
|
||||
val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
|
||||
val path = map[EntryFields.PATH] as String? // best effort to get local path
|
||||
val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
|
||||
val mimeType = map[EntryFields.MIME_TYPE] as String
|
||||
val width = map[EntryFields.WIDTH] as Int
|
||||
val height = map[EntryFields.HEIGHT] as Int
|
||||
val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
|
||||
val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
|
||||
val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||
val trashed = map[EntryFields.TRASHED] as Boolean
|
||||
val trashPath = map[EntryFields.TRASH_PATH] as String?
|
||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||
val path = map["path"] as String? // best effort to get local path
|
||||
val pageId = map["pageId"] as Int? // null means the main entry
|
||||
val mimeType = map["mimeType"] as String
|
||||
val width = map["width"] as Int
|
||||
val height = map["height"] as Int
|
||||
val rotationDegrees = map["rotationDegrees"] as Int
|
||||
val isFlipped = map["isFlipped"] as Boolean
|
||||
val sizeBytes = toLong(map["sizeBytes"])
|
||||
val trashed = map["trashed"] as Boolean
|
||||
val trashPath = map["trashPath"] as String?
|
||||
|
||||
private val isRotated: Boolean
|
||||
val isRotated: Boolean
|
||||
get() = rotationDegrees % 180 == 90
|
||||
|
||||
val displayWidth: Int
|
||||
get() = if (isRotated) height else width
|
||||
|
||||
val displayHeight: Int
|
||||
get() = if (isRotated) width else height
|
||||
|
||||
companion object {
|
||||
// convenience method
|
||||
private fun toLong(o: Any?): Long? = when (o) {
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
// entry fields exported and imported from/to the platform side
|
||||
// should match `EntryFields` on Dart side
|
||||
object EntryFields {
|
||||
const val ORIGIN = "origin" // int
|
||||
const val URI = "uri" // string
|
||||
const val CONTENT_ID = "contentId" // long
|
||||
const val PATH = "path" // string
|
||||
const val PAGE_ID = "pageId" // int
|
||||
const val SOURCE_MIME_TYPE = "sourceMimeType" // string
|
||||
const val MIME_TYPE = "mimeType" // string
|
||||
|
||||
const val WIDTH = "width" // int
|
||||
const val HEIGHT = "height" // int
|
||||
const val SOURCE_ROTATION_DEGREES = "sourceRotationDegrees" // int
|
||||
const val ROTATION_DEGREES = "rotationDegrees" // int
|
||||
const val IS_FLIPPED = "isFlipped" // boolean
|
||||
|
||||
const val DATE_ADDED_SECS = "dateAddedSecs" // long
|
||||
const val DATE_MODIFIED_MILLIS = "dateModifiedMillis" // long
|
||||
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
|
||||
const val DURATION_MILLIS = "durationMillis" // long
|
||||
|
||||
const val SIZE_BYTES = "sizeBytes" // long
|
||||
const val TRASHED = "trashed" // boolean
|
||||
const val TRASH_PATH = "trashPath" // string
|
||||
const val TITLE = "title" // string
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import java.io.File
|
||||
|
||||
enum class NameConflictStrategy {
|
||||
RENAME, REPLACE, SKIP;
|
||||
|
||||
|
@ -11,6 +9,4 @@ enum class NameConflictStrategy {
|
|||
return valueOf(name.uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NameConflictResolution(var nameWithoutExtension: String?, var replacementFile: File?)
|
||||
}
|
|
@ -5,7 +5,7 @@ import android.content.Context
|
|||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.metadata.avi.AviDirectory
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.jpeg.JpegDirectory
|
||||
|
@ -29,7 +29,6 @@ import deckers.thibault.aves.utils.StorageUtils
|
|||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
class SourceEntry {
|
||||
private val origin: Int
|
||||
|
@ -42,7 +41,7 @@ class SourceEntry {
|
|||
private var sourceRotationDegrees: Int? = null
|
||||
private var sizeBytes: Long? = null
|
||||
private var dateAddedSecs: Long? = null
|
||||
private var dateModifiedMillis: Long? = null
|
||||
private var dateModifiedSecs: Long? = null
|
||||
private var sourceDateTakenMillis: Long? = null
|
||||
private var durationMillis: Long? = null
|
||||
|
||||
|
@ -55,45 +54,45 @@ class SourceEntry {
|
|||
}
|
||||
|
||||
constructor(map: FieldMap) {
|
||||
origin = map[EntryFields.ORIGIN] as Int
|
||||
uri = (map[EntryFields.URI] as String).toUri()
|
||||
path = map[EntryFields.PATH] as String?
|
||||
sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
|
||||
width = map[EntryFields.WIDTH] as Int?
|
||||
height = map[EntryFields.HEIGHT] as Int?
|
||||
sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
|
||||
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||
title = map[EntryFields.TITLE] as String?
|
||||
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
|
||||
dateModifiedMillis = toLong(map[EntryFields.DATE_MODIFIED_MILLIS])
|
||||
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
|
||||
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
|
||||
origin = map["origin"] as Int
|
||||
uri = Uri.parse(map["uri"] as String)
|
||||
path = map["path"] as String?
|
||||
sourceMimeType = map["sourceMimeType"] as String
|
||||
width = map["width"] as Int?
|
||||
height = map["height"] as Int?
|
||||
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
|
||||
sizeBytes = toLong(map["sizeBytes"])
|
||||
title = map["title"] as String?
|
||||
dateAddedSecs = toLong(map["dateAddedSecs"])
|
||||
dateModifiedSecs = toLong(map["dateModifiedSecs"])
|
||||
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
|
||||
durationMillis = toLong(map["durationMillis"])
|
||||
}
|
||||
|
||||
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedMillis: Long) {
|
||||
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
|
||||
this.path = path
|
||||
this.title = title
|
||||
this.sizeBytes = sizeBytes
|
||||
this.dateModifiedMillis = dateModifiedMillis
|
||||
this.dateModifiedSecs = dateModifiedSecs
|
||||
}
|
||||
|
||||
fun toMap(): FieldMap {
|
||||
return hashMapOf(
|
||||
EntryFields.ORIGIN to origin,
|
||||
EntryFields.URI to uri.toString(),
|
||||
EntryFields.PATH to path,
|
||||
EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
|
||||
EntryFields.WIDTH to width,
|
||||
EntryFields.HEIGHT to height,
|
||||
EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
|
||||
EntryFields.SIZE_BYTES to sizeBytes,
|
||||
EntryFields.TITLE to title,
|
||||
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
|
||||
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
||||
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
|
||||
EntryFields.DURATION_MILLIS to durationMillis,
|
||||
"origin" to origin,
|
||||
"uri" to uri.toString(),
|
||||
"path" to path,
|
||||
"sourceMimeType" to sourceMimeType,
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
||||
"sizeBytes" to sizeBytes,
|
||||
"title" to title,
|
||||
"dateAddedSecs" to dateAddedSecs,
|
||||
"dateModifiedSecs" to dateModifiedSecs,
|
||||
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
||||
"durationMillis" to durationMillis,
|
||||
// only for map export
|
||||
EntryFields.CONTENT_ID to contentId,
|
||||
"contentId" to contentId,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -164,7 +163,7 @@ class SourceEntry {
|
|||
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input, sizeBytes)
|
||||
val metadata = Helper.safeRead(input)
|
||||
|
||||
// do not switch on specific MIME types, as the reported MIME type could be wrong
|
||||
// (e.g. PNG registered as JPG)
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.util.Locale
|
||||
|
||||
class AvesEmbeddedMediaProvider : UnknownContentProvider() {
|
||||
override val reliableProviderMimeType: Boolean
|
||||
get() = true
|
||||
|
||||
companion object {
|
||||
fun provides(context: Context, uri: Uri): Boolean {
|
||||
if (uri.scheme?.lowercase(Locale.ROOT) != ContentResolver.SCHEME_CONTENT) return false
|
||||
return uri.authority == "${context.applicationContext.packageName}.file_provider"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
||||
internal class ContentImageProvider : ImageProvider() {
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||
var extractorMimeType: String? = null
|
||||
try {
|
||||
val safeUri = Uri.fromFile(Metadata.createPreviewFile(context, uri))
|
||||
StorageUtils.openInputStream(context, safeUri)?.use { input ->
|
||||
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
|
||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
|
||||
Helper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
|
||||
extractorMimeType = it
|
||||
if (extractorMimeType != sourceMimeType) {
|
||||
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $extractorMimeType for uri=$uri")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: AssertionError) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
}
|
||||
|
||||
val mimeType = extractorMimeType ?: sourceMimeType
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||
"uri" to uri.toString(),
|
||||
"sourceMimeType" to mimeType,
|
||||
)
|
||||
try {
|
||||
// some providers do not provide the mandatory `OpenableColumns`
|
||||
// and the query fails when compiling a projection specifying them
|
||||
// e.g. `content://mms/part/[id]` on Android KitKat
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = cursor.getString(it) }
|
||||
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields["sourceMimeType"] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(Exception("Failed to query content, error=${e.message}"))
|
||||
return
|
||||
}
|
||||
|
||||
if (fields["sourceMimeType"] == null) {
|
||||
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
||||
return
|
||||
}
|
||||
|
||||
val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
|
||||
if (entry.isSized || entry.isSvg || entry.isVideo) {
|
||||
callback.onSuccess(entry.toMap())
|
||||
} else {
|
||||
callback.onFailure(Exception("entry has no size"))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
|
||||
}
|
||||
}
|
|
@ -6,27 +6,18 @@ import android.content.ContextWrapper
|
|||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.io.File
|
||||
|
||||
internal class FileImageProvider : ImageProvider() {
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
var mimeType = sourceMimeType
|
||||
|
||||
if (mimeType == null) {
|
||||
var extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
|
||||
if (extension.isEmpty()) {
|
||||
uri.path?.let { path ->
|
||||
val lastDotIndex = path.lastIndexOf('.')
|
||||
if (lastDotIndex >= 0) {
|
||||
extension = path.substring(lastDotIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extension.isNotEmpty()) {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
|
||||
if (extension != null) {
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
}
|
||||
}
|
||||
|
@ -46,17 +37,16 @@ internal class FileImageProvider : ImageProvider() {
|
|||
path = path,
|
||||
title = file.name,
|
||||
sizeBytes = file.length(),
|
||||
dateModifiedMillis = file.lastModified(),
|
||||
dateModifiedSecs = file.lastModified() / 1000,
|
||||
)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
entry.fillPreCatalogMetadata(context)
|
||||
|
||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||
if (entry.isSized || entry.isSvg || entry.isVideo) {
|
||||
callback.onSuccess(entry.toMap())
|
||||
} else {
|
||||
callback.onFailure(Exception("entry has no size"))
|
||||
|
@ -89,9 +79,9 @@ internal class FileImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
return hashMapOf(
|
||||
EntryFields.URI to Uri.fromFile(newFile).toString(),
|
||||
EntryFields.PATH to newFile.path,
|
||||
EntryFields.DATE_MODIFIED_MILLIS to newFile.lastModified(),
|
||||
"uri" to Uri.fromFile(newFile).toString(),
|
||||
"path" to newFile.path,
|
||||
"dateModifiedSecs" to newFile.lastModified() / 1000,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -99,8 +89,8 @@ internal class FileImageProvider : ImageProvider() {
|
|||
try {
|
||||
val file = File(path)
|
||||
if (file.exists()) {
|
||||
newFields[EntryFields.DATE_MODIFIED_MILLIS] = file.lastModified()
|
||||
newFields[EntryFields.SIZE_BYTES] = file.length()
|
||||
newFields["dateModifiedSecs"] = file.lastModified() / 1000
|
||||
newFields["sizeBytes"] = file.length()
|
||||
}
|
||||
callback.onSuccess(newFields)
|
||||
} catch (e: SecurityException) {
|
||||
|
|
|
@ -11,63 +11,52 @@ import android.net.Uri
|
|||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.FutureTarget
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.SvgImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateLocation
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.NameConflictResolution
|
||||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BmpWriter
|
||||
import deckers.thibault.aves.model.*
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||
import deckers.thibault.aves.utils.FileUtils.transferTo
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
|
||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import pixy.meta.meta.Metadata
|
||||
import pixy.meta.meta.MetadataType
|
||||
import java.io.*
|
||||
import java.nio.channels.Channels
|
||||
import java.util.Date
|
||||
import java.util.TimeZone
|
||||
import java.util.*
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||
|
||||
abstract class ImageProvider {
|
||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
|
||||
}
|
||||
|
||||
|
@ -75,10 +64,10 @@ abstract class ImageProvider {
|
|||
return if (StorageUtils.isInVault(context, path)) {
|
||||
val uri = Uri.fromFile(File(path))
|
||||
hashMapOf(
|
||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
|
||||
EntryFields.URI to uri.toString(),
|
||||
EntryFields.CONTENT_ID to null,
|
||||
EntryFields.PATH to path,
|
||||
"origin" to SourceEntry.ORIGIN_VAULT,
|
||||
"uri" to uri.toString(),
|
||||
"contentId" to null,
|
||||
"path" to path,
|
||||
)
|
||||
} else {
|
||||
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
||||
|
@ -134,7 +123,8 @@ abstract class ImageProvider {
|
|||
"success" to false,
|
||||
)
|
||||
|
||||
if (sourcePath != null) {
|
||||
// prevent naming with a `.` prefix as it would hide the file and remove it from the Media Store
|
||||
if (sourcePath != null && !desiredName.startsWith('.')) {
|
||||
try {
|
||||
var newFields: FieldMap = skippedFieldMap
|
||||
if (!isCancelledOp()) {
|
||||
|
@ -142,18 +132,15 @@ abstract class ImageProvider {
|
|||
|
||||
val oldFile = File(sourcePath)
|
||||
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
|
||||
val defaultExtension = oldFile.extension
|
||||
oldFile.parent?.let { dir ->
|
||||
val resolution = resolveTargetFileNameWithoutExtension(
|
||||
resolveTargetFileNameWithoutExtension(
|
||||
contextWrapper = activity,
|
||||
dir = dir,
|
||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||
mimeType = mimeType,
|
||||
defaultExtension = defaultExtension,
|
||||
conflictStrategy = NameConflictStrategy.RENAME,
|
||||
)
|
||||
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
|
||||
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}"
|
||||
)?.let { targetNameWithoutExtension ->
|
||||
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
|
||||
val newFile = File(dir, targetFileName)
|
||||
if (oldFile != newFile) {
|
||||
newFields = renameSingle(
|
||||
|
@ -193,7 +180,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
suspend fun convertMultiple(
|
||||
activity: Activity,
|
||||
activity: FragmentActivity,
|
||||
imageExportMimeType: String,
|
||||
targetDir: String,
|
||||
entries: List<AvesEntry>,
|
||||
|
@ -207,7 +194,6 @@ abstract class ImageProvider {
|
|||
) {
|
||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||
callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType"))
|
||||
return
|
||||
}
|
||||
|
||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
|
@ -253,7 +239,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
private suspend fun convertSingle(
|
||||
activity: Activity,
|
||||
activity: FragmentActivity,
|
||||
sourceEntry: AvesEntry,
|
||||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
|
@ -266,7 +252,7 @@ abstract class ImageProvider {
|
|||
exportMimeType: String,
|
||||
): FieldMap {
|
||||
val sourceMimeType = sourceEntry.mimeType
|
||||
var sourceUri = sourceEntry.uri
|
||||
val sourceUri = sourceEntry.uri
|
||||
val pageId = sourceEntry.pageId
|
||||
|
||||
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
||||
|
@ -279,23 +265,13 @@ abstract class ImageProvider {
|
|||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
||||
}
|
||||
|
||||
// there is no benefit providing input extension
|
||||
// for known output MIME type
|
||||
val defaultExtension = null
|
||||
|
||||
val resolution = resolveTargetFileNameWithoutExtension(
|
||||
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
|
||||
contextWrapper = activity,
|
||||
dir = targetDir,
|
||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||
mimeType = exportMimeType,
|
||||
defaultExtension = defaultExtension,
|
||||
conflictStrategy = nameConflictStrategy,
|
||||
)
|
||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||
resolution.replacementFile?.let { file ->
|
||||
sourceUri = Uri.fromFile(file)
|
||||
}
|
||||
) ?: return skippedFieldMap
|
||||
|
||||
val targetMimeType: String
|
||||
val write: (OutputStream) -> Unit
|
||||
|
@ -308,28 +284,36 @@ abstract class ImageProvider {
|
|||
sourceDocFile.copyTo(output)
|
||||
}
|
||||
} else {
|
||||
val targetWidthPx: Int
|
||||
val targetHeightPx: Int
|
||||
when (lengthUnit) {
|
||||
LENGTH_UNIT_PERCENT -> {
|
||||
targetWidthPx = sourceEntry.displayWidth * width / 100
|
||||
targetHeightPx = sourceEntry.displayHeight * height / 100
|
||||
}
|
||||
|
||||
else -> {
|
||||
targetWidthPx = width
|
||||
targetHeightPx = height
|
||||
}
|
||||
var targetWidthPx: Int = if (sourceEntry.isRotated) height else width
|
||||
var targetHeightPx: Int = if (sourceEntry.isRotated) width else height
|
||||
if (lengthUnit == LENGTH_UNIT_PERCENT) {
|
||||
targetWidthPx = sourceEntry.width * targetWidthPx / 100
|
||||
targetHeightPx = sourceEntry.height * targetHeightPx / 100
|
||||
}
|
||||
|
||||
target = Glide.with(activity.applicationContext)
|
||||
.asBitmap()
|
||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||
.load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
|
||||
.submit(targetWidthPx, targetHeightPx)
|
||||
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||
MultiTrackImage(activity, sourceUri, pageId)
|
||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||
TiffImage(activity, sourceUri, pageId)
|
||||
} else if (sourceMimeType == MimeTypes.SVG) {
|
||||
SvgImage(activity, sourceUri)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType, sourceEntry.sizeBytes)
|
||||
}
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
val glideOptions = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
.submit(targetWidthPx, targetHeightPx)
|
||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||
}
|
||||
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
|
||||
|
@ -366,12 +350,11 @@ abstract class ImageProvider {
|
|||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||
defaultExtension = defaultExtension,
|
||||
write = write,
|
||||
)
|
||||
|
||||
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
||||
val targetUri = (newFields[EntryFields.URI] as String).toUri()
|
||||
val targetUri = Uri.parse(newFields["uri"] as String)
|
||||
if (writeMetadata) {
|
||||
copyMetadata(
|
||||
context = activity,
|
||||
|
@ -386,9 +369,7 @@ abstract class ImageProvider {
|
|||
return newFields
|
||||
} finally {
|
||||
// clearing Glide target should happen after effectively writing the bitmap
|
||||
Glide.with(activity.applicationContext).clear(target)
|
||||
|
||||
resolution.replacementFile?.delete()
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -407,7 +388,39 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
// copy IPTC / XMP via PixyMeta
|
||||
PixyMetaHelper.copyIptcXmp(context, sourceMimeType, sourceUri, targetMimeType, targetUri, editableFile)
|
||||
|
||||
var pixyIptc: pixy.meta.meta.iptc.IPTC? = null
|
||||
var pixyXmp: pixy.meta.meta.xmp.XMP? = null
|
||||
if (canReadWithPixyMeta(sourceMimeType)) {
|
||||
StorageUtils.openInputStream(context, sourceUri)?.use { input ->
|
||||
val metadata = Metadata.readMetadata(input)
|
||||
if (canEditIptc(targetMimeType)) {
|
||||
pixyIptc = metadata[MetadataType.IPTC] as pixy.meta.meta.iptc.IPTC?
|
||||
}
|
||||
if (canEditXmp(targetMimeType)) {
|
||||
pixyXmp = metadata[MetadataType.XMP] as pixy.meta.meta.xmp.XMP?
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pixyIptc != null || pixyXmp != null) {
|
||||
editableFile.outputStream().use { output ->
|
||||
if (pixyIptc != null) {
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, targetUri)?.use { input ->
|
||||
val iptcs = pixyIptc!!.dataSets.flatMap { it.value }
|
||||
Metadata.insertIPTC(input, output, iptcs)
|
||||
}
|
||||
}
|
||||
if (pixyXmp != null) {
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, targetUri)?.use { input ->
|
||||
val xmpString = pixyXmp!!.xmpDocString()
|
||||
val extendedXmp = if (pixyXmp!!.hasExtendedXmp()) pixyXmp!!.extendedXmpDocString() else null
|
||||
PixyMetaHelper.setXmp(input, output, xmpString, if (targetMimeType == MimeTypes.JPEG) extendedXmp else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copy Exif via ExifInterface
|
||||
|
||||
|
@ -468,13 +481,12 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val captureMimeType = MimeTypes.JPEG
|
||||
val resolution = try {
|
||||
val targetNameWithoutExtension = try {
|
||||
resolveTargetFileNameWithoutExtension(
|
||||
contextWrapper = contextWrapper,
|
||||
dir = targetDir,
|
||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||
mimeType = captureMimeType,
|
||||
defaultExtension = null,
|
||||
conflictStrategy = nameConflictStrategy,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
|
@ -482,7 +494,6 @@ abstract class ImageProvider {
|
|||
return
|
||||
}
|
||||
|
||||
val targetNameWithoutExtension = resolution.nameWithoutExtension
|
||||
if (targetNameWithoutExtension == null) {
|
||||
// skip it
|
||||
callback.onSuccess(skippedFieldMap)
|
||||
|
@ -561,65 +572,42 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fun createTimeStampFileName() = Date().time.toString()
|
||||
|
||||
private fun sanitizeDesiredFileName(desiredName: String): String {
|
||||
var name = desiredName
|
||||
// prevent creating hidden files
|
||||
while (name.isNotEmpty() && name.startsWith(".")) {
|
||||
name = name.substring(1)
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = createTimeStampFileName()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// returns available name to use, or `null` to skip it
|
||||
suspend fun resolveTargetFileNameWithoutExtension(
|
||||
contextWrapper: ContextWrapper,
|
||||
dir: String,
|
||||
desiredNameWithoutExtension: String,
|
||||
mimeType: String,
|
||||
defaultExtension: String?,
|
||||
conflictStrategy: NameConflictStrategy,
|
||||
): NameConflictResolution {
|
||||
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
|
||||
var resolvedName: String? = sanitizedNameWithoutExtension
|
||||
var replacementFile: File? = null
|
||||
|
||||
val extension = extensionFor(mimeType, defaultExtension)
|
||||
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
|
||||
when (conflictStrategy) {
|
||||
): String? {
|
||||
val extension = extensionFor(mimeType)
|
||||
val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
|
||||
return when (conflictStrategy) {
|
||||
NameConflictStrategy.RENAME -> {
|
||||
var nameWithoutExtension = sanitizedNameWithoutExtension
|
||||
var nameWithoutExtension = desiredNameWithoutExtension
|
||||
var i = 0
|
||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||
i++
|
||||
nameWithoutExtension = "$sanitizedNameWithoutExtension ($i)"
|
||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
||||
}
|
||||
resolvedName = nameWithoutExtension
|
||||
nameWithoutExtension
|
||||
}
|
||||
|
||||
NameConflictStrategy.REPLACE -> {
|
||||
if (targetFile.exists()) {
|
||||
// move replaced file to temp storage
|
||||
// so that it can be used as a source for conversion or metadata copy
|
||||
replacementFile = StorageUtils.createTempFile(contextWrapper).apply {
|
||||
targetFile.transferTo(outputStream())
|
||||
}
|
||||
deletePath(contextWrapper, targetFile.path, mimeType)
|
||||
}
|
||||
desiredNameWithoutExtension
|
||||
}
|
||||
|
||||
NameConflictStrategy.SKIP -> {
|
||||
if (targetFile.exists()) {
|
||||
resolvedName = null
|
||||
null
|
||||
} else {
|
||||
desiredNameWithoutExtension
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NameConflictResolution(resolvedName, replacementFile)
|
||||
}
|
||||
|
||||
// cf `MetadataFetchHandler.getCatalogMetadataByMetadataExtractor()` for a more thorough check
|
||||
|
@ -657,21 +645,19 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
var trailerVideoBytes: ByteArray? = null
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
var videoBytes: ByteArray? = null
|
||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||
try {
|
||||
if (trailerVideoSize != null && isTrailerVideoValid) {
|
||||
if (videoSize != null) {
|
||||
// handle motion photo and embedded video separately
|
||||
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
||||
val videoByteSize = trailerVideoSize.toInt()
|
||||
trailerVideoBytes = ByteArray(videoByteSize)
|
||||
val imageSize = (originalFileSize - videoSize).toInt()
|
||||
videoBytes = ByteArray(videoSize)
|
||||
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
val imageBytes = ByteArray(imageSize)
|
||||
input.read(imageBytes, 0, imageSize)
|
||||
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||
input.read(videoBytes, 0, videoSize)
|
||||
|
||||
// copy only the image to a temporary file for editing
|
||||
// video will be appended after metadata modification
|
||||
|
@ -691,31 +677,30 @@ abstract class ImageProvider {
|
|||
try {
|
||||
edit(ExifInterface(editableFile))
|
||||
|
||||
if (editableFile.length() == 0L) {
|
||||
callback.onFailure(Exception("editing Exif yielded an empty file"))
|
||||
return false
|
||||
}
|
||||
|
||||
val editedMimeType = detectMimeType(context, Uri.fromFile(editableFile), mimeType)
|
||||
if (editedMimeType != mimeType) {
|
||||
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
// editing may corrupt the file for various reasons,
|
||||
// 1) as of androidx.exifinterface:exifinterface:1.3.6, editing some specific WEBP
|
||||
// makes them undecodable by some decoders (including Android's and Chrome's)
|
||||
// even though `BitmapFactory` successfully decodes their bounds,
|
||||
// so we check whether decoding it throws an exception
|
||||
// 2) some users have reported corruption when editing JPEG as well,
|
||||
// but conditions are unknown (specific image, custom ROM, low storage, race condition, etc.)
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
||||
}
|
||||
|
||||
if (trailerVideoBytes != null) {
|
||||
if (videoBytes != null) {
|
||||
// append trailer video, if any
|
||||
editableFile.appendBytes(trailerVideoBytes!!)
|
||||
editableFile.appendBytes(videoBytes!!)
|
||||
}
|
||||
|
||||
// copy the edited temporary file back to the original
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
|
@ -743,21 +728,19 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
var trailerVideoBytes: ByteArray? = null
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
var videoBytes: ByteArray? = null
|
||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||
try {
|
||||
if (trailerVideoSize != null && isTrailerVideoValid) {
|
||||
if (videoSize != null) {
|
||||
// handle motion photo and embedded video separately
|
||||
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
||||
val videoByteSize = trailerVideoSize.toInt()
|
||||
trailerVideoBytes = ByteArray(videoByteSize)
|
||||
val imageSize = (originalFileSize - videoSize).toInt()
|
||||
videoBytes = ByteArray(videoSize)
|
||||
|
||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||
val imageBytes = ByteArray(imageSize)
|
||||
input.read(imageBytes, 0, imageSize)
|
||||
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||
input.read(videoBytes, 0, videoSize)
|
||||
|
||||
// copy only the image to a temporary file for editing
|
||||
// video will be appended after metadata modification
|
||||
|
@ -793,20 +776,15 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (editableFile.length() == 0L) {
|
||||
callback.onFailure(Exception("editing IPTC yielded an empty file"))
|
||||
return false
|
||||
}
|
||||
|
||||
if (trailerVideoBytes != null) {
|
||||
if (videoBytes != null) {
|
||||
// append trailer video, if any
|
||||
editableFile.appendBytes(trailerVideoBytes!!)
|
||||
editableFile.appendBytes(videoBytes!!)
|
||||
}
|
||||
|
||||
// copy the edited temporary file back to the original
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
|
@ -876,7 +854,6 @@ abstract class ImageProvider {
|
|||
}
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
|
@ -916,7 +893,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||
try {
|
||||
editXmpWithPixy(
|
||||
|
@ -934,16 +911,11 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (editableFile.length() == 0L) {
|
||||
callback.onFailure(Exception("editing XMP yielded an empty file"))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
|
@ -1004,7 +976,7 @@ abstract class ImageProvider {
|
|||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
trailerOffset: Number?,
|
||||
trailerOffset: Int?,
|
||||
editedFile: File,
|
||||
callback: ImageOpCallback,
|
||||
): Boolean {
|
||||
|
@ -1019,9 +991,17 @@ abstract class ImageProvider {
|
|||
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
||||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||
)
|
||||
val newTrailerOffset = trailerOffset.toLong() + diff
|
||||
val newTrailerOffset = trailerOffset + diff
|
||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
||||
xmp.replace(
|
||||
// GCamera motion photo
|
||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$newTrailerOffset\"",
|
||||
).replace(
|
||||
// Container motion photo
|
||||
"${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
||||
"${XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1065,7 +1045,7 @@ abstract class ImageProvider {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
dateMillis: Long?,
|
||||
shiftSeconds: Long?,
|
||||
shiftMinutes: Long?,
|
||||
fields: List<String>,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
|
@ -1096,9 +1076,9 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
shiftSeconds != null -> {
|
||||
shiftMinutes != null -> {
|
||||
// shift
|
||||
val shiftMillis = shiftSeconds * 1000
|
||||
val shiftMillis = shiftMinutes * 60000
|
||||
listOf(
|
||||
ExifInterface.TAG_DATETIME,
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL,
|
||||
|
@ -1284,23 +1264,17 @@ abstract class ImageProvider {
|
|||
callback: ImageOpCallback,
|
||||
) {
|
||||
val originalFileSize = File(path).length()
|
||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
||||
if (trailerVideoSize == null) {
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||
if (videoSize == null) {
|
||||
callback.onFailure(Exception("failed to get trailer video size"))
|
||||
return
|
||||
}
|
||||
|
||||
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSize = originalFileSize, videoSize = trailerVideoSize) != null
|
||||
if (!isTrailerVideoValid) {
|
||||
callback.onFailure(Exception("failed to open trailer video with size=$trailerVideoSize"))
|
||||
return
|
||||
}
|
||||
|
||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||
try {
|
||||
val inputStream = StorageUtils.openInputStream(context, uri)
|
||||
// partial copy
|
||||
transferFrom(inputStream, originalFileSize - trailerVideoSize)
|
||||
transferFrom(inputStream, originalFileSize - videoSize)
|
||||
} catch (e: Exception) {
|
||||
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
||||
callback.onFailure(e)
|
||||
|
@ -1335,8 +1309,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
||||
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||
try {
|
||||
outputStream().use { output ->
|
||||
|
@ -1352,16 +1325,11 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (editableFile.length() == 0L) {
|
||||
callback.onFailure(Exception("removing metadata yielded an empty file"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
|
||||
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return
|
||||
}
|
||||
editableFile.delete()
|
||||
|
|
|
@ -1,24 +1,20 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.util.Locale
|
||||
import java.util.*
|
||||
|
||||
object ImageProviderFactory {
|
||||
fun getProvider(context: Context, uri: Uri): ImageProvider? {
|
||||
fun getProvider(uri: Uri): ImageProvider? {
|
||||
return when (uri.scheme?.lowercase(Locale.ROOT)) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
if (StorageUtils.isMediaStoreContentUri(uri)) {
|
||||
MediaStoreImageProvider()
|
||||
} else if (AvesEmbeddedMediaProvider.provides(context, uri)) {
|
||||
AvesEmbeddedMediaProvider()
|
||||
} else {
|
||||
UnknownContentProvider()
|
||||
ContentImageProvider()
|
||||
}
|
||||
}
|
||||
|
||||
ContentResolver.SCHEME_FILE -> FileImageProvider()
|
||||
else -> null
|
||||
}
|
||||
|
|
|
@ -3,16 +3,10 @@ package deckers.thibault.aves.model.provider
|
|||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.RecoverableSecurityException
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.BitmapFactory
|
||||
import android.content.*
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
|
@ -20,7 +14,6 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
|||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
|
@ -38,10 +31,9 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.io.SyncFailedException
|
||||
import java.util.Locale
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
@ -51,14 +43,13 @@ import kotlin.coroutines.suspendCoroutine
|
|||
class MediaStoreImageProvider : ImageProvider() {
|
||||
fun fetchAll(
|
||||
context: Context,
|
||||
knownEntries: Map<Long?, Long?>,
|
||||
knownEntries: Map<Int?, Int?>,
|
||||
directory: String?,
|
||||
handleNewEntry: NewEntryHandler,
|
||||
) {
|
||||
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory")
|
||||
val isModified = fun(contentId: Long, dateModifiedMillis: Long): Boolean {
|
||||
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
|
||||
val knownDate = knownEntries[contentId]
|
||||
return knownDate == null || knownDate < dateModifiedMillis
|
||||
return knownDate == null || knownDate < dateModifiedSecs
|
||||
}
|
||||
val handleNew: NewEntryHandler
|
||||
var selection: String? = null
|
||||
|
@ -77,7 +68,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val parentCheckDirectory = removeTrailingSeparator(directory)
|
||||
handleNew = { entry ->
|
||||
// skip entries in subfolders
|
||||
val path = entry[EntryFields.PATH] as String?
|
||||
val path = entry["path"] as String?
|
||||
if (path != null && File(path).parent == parentCheckDirectory) {
|
||||
handleNewEntry(entry)
|
||||
}
|
||||
|
@ -92,11 +83,11 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// the provided URI can point to the wrong media collection,
|
||||
// e.g. a GIF image with the URI `content://media/external/video/media/[ID]`
|
||||
// so the effective entry URI may not match the provided URI
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
var found = false
|
||||
val fetched = arrayListOf<FieldMap>()
|
||||
val id = uri.tryParseId()
|
||||
val alwaysValid: NewEntryChecker = fun(_: Long, _: Long): Boolean = true
|
||||
val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
|
||||
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
||||
if (id != null) {
|
||||
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
||||
|
@ -126,8 +117,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
}
|
||||
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Long?>): List<Long> {
|
||||
val foundContentIds = HashSet<Long>()
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int?>): List<Int> {
|
||||
val foundContentIds = HashSet<Int>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
try {
|
||||
|
@ -135,7 +126,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
if (cursor != null) {
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
while (cursor.moveToNext()) {
|
||||
foundContentIds.add(cursor.getLong(idColumn))
|
||||
foundContentIds.add(cursor.getInt(idColumn))
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
@ -148,8 +139,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
return knownContentIds.subtract(foundContentIds).filterNotNull().toList()
|
||||
}
|
||||
|
||||
fun checkObsoletePaths(context: Context, knownPathById: Map<Long?, String?>): List<Long> {
|
||||
val obsoleteIds = ArrayList<Long>()
|
||||
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> {
|
||||
val obsoleteIds = ArrayList<Int>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
|
||||
try {
|
||||
|
@ -158,7 +149,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val id = cursor.getInt(idColumn)
|
||||
val path = cursor.getString(pathColumn)
|
||||
if (knownPathById.containsKey(id) && knownPathById[id] != path) {
|
||||
obsoleteIds.add(id)
|
||||
|
@ -175,31 +166,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
return obsoleteIds
|
||||
}
|
||||
|
||||
fun getChangedUris(context: Context, sinceGeneration: Int): List<String> {
|
||||
val changedUris = ArrayList<String>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
val selection = "${MediaStore.MediaColumns.GENERATION_MODIFIED} > ?"
|
||||
val selectionArgs = arrayOf(sinceGeneration.toString())
|
||||
try {
|
||||
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null)
|
||||
if (cursor != null) {
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
changedUris.add(ContentUris.withAppendedId(contentUri, id).toString())
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e)
|
||||
}
|
||||
}
|
||||
check(context, IMAGE_CONTENT_URI)
|
||||
check(context, VIDEO_CONTENT_URI)
|
||||
return changedUris
|
||||
}
|
||||
|
||||
private fun fetchFrom(
|
||||
context: Context,
|
||||
isValidEntry: NewEntryChecker,
|
||||
|
@ -227,8 +193,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||
val dateAddedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||
val dateModifiedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
||||
|
||||
// image & video for API >=29, only for images for API <29
|
||||
|
@ -239,62 +205,39 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val needDuration = projection.contentEquals(VIDEO_PROJECTION)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val dateModifiedMillis = cursor.getInt(dateModifiedSecsColumn) * 1000L
|
||||
if (isValidEntry(id, dateModifiedMillis)) {
|
||||
val contentId = cursor.getInt(idColumn)
|
||||
val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
|
||||
if (isValidEntry(contentId, dateModifiedSecs)) {
|
||||
// for multiple items, `contentUri` is the root without ID,
|
||||
// but for single items, `contentUri` already contains the ID
|
||||
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id)
|
||||
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, contentId.toLong())
|
||||
// `mimeType` can be registered as null for file media URIs with unsupported media types (e.g. TIFF on old devices)
|
||||
// in that case we try to use the MIME type provided along the URI
|
||||
val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType
|
||||
var width = cursor.getInt(widthColumn)
|
||||
var height = cursor.getInt(heightColumn)
|
||||
val width = cursor.getInt(widthColumn)
|
||||
val height = cursor.getInt(heightColumn)
|
||||
val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L
|
||||
|
||||
if (mimeType == null) {
|
||||
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||
} else {
|
||||
val path = cursor.getString(pathColumn)
|
||||
var entryFields: FieldMap = hashMapOf(
|
||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||
EntryFields.URI to itemUri.toString(),
|
||||
EntryFields.PATH to path,
|
||||
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||
EntryFields.WIDTH to width,
|
||||
EntryFields.HEIGHT to height,
|
||||
EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||
EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
|
||||
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedSecsColumn),
|
||||
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
||||
EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||
EntryFields.DURATION_MILLIS to durationMillis,
|
||||
var entryMap: FieldMap = hashMapOf(
|
||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||
"uri" to itemUri.toString(),
|
||||
"path" to cursor.getString(pathColumn),
|
||||
"sourceMimeType" to mimeType,
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||
"sizeBytes" to cursor.getLong(sizeColumn),
|
||||
"dateAddedSecs" to cursor.getInt(dateAddedColumn),
|
||||
"dateModifiedSecs" to dateModifiedSecs,
|
||||
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||
"durationMillis" to durationMillis,
|
||||
// only for map export
|
||||
EntryFields.CONTENT_ID to id,
|
||||
"contentId" to contentId,
|
||||
)
|
||||
|
||||
if (MimeTypes.isHeic(mimeType)) {
|
||||
// The reported size for some HEIC images is simply incorrect.
|
||||
try {
|
||||
StorageUtils.openInputStream(context, itemUri)?.use { input ->
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
val outWidth = options.outWidth
|
||||
val outHeight = options.outHeight
|
||||
if (outWidth > 0 && outHeight > 0) {
|
||||
width = outWidth
|
||||
height = outHeight
|
||||
entryFields[EntryFields.WIDTH] = width
|
||||
entryFields[EntryFields.HEIGHT] = height
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (MimeTypes.isRaw(mimeType)
|
||||
|| (width <= 0 || height <= 0) && needSize(mimeType)
|
||||
|| durationMillis == 0L && needDuration
|
||||
|
@ -303,13 +246,11 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// missing some attributes such as width, height, orientation.
|
||||
// Also, the reported size of raw images is inconsistent across devices
|
||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||
val entry = SourceEntry(entryFields).fillPreCatalogMetadata(context)
|
||||
entryFields = entry.toMap()
|
||||
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context)
|
||||
entryMap = entry.toMap()
|
||||
}
|
||||
|
||||
getFileModifiedDateMillis(path)?.let { entryFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||
|
||||
handleNewEntry(entryFields)
|
||||
handleNewEntry(entryMap)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
@ -457,8 +398,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
effectiveTargetDir = targetDir
|
||||
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
|
||||
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
|
||||
// download subdirectories can be created later by Media Store insertion
|
||||
if (!isDownloadSubdir(activity, targetDir)) {
|
||||
if (!isDownloadSubdir) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||
return
|
||||
}
|
||||
|
@ -482,62 +425,64 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
"success" to false,
|
||||
)
|
||||
|
||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||
//
|
||||
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||
// - we need to scan the file to get the Media Store content URI
|
||||
// - the underlying document provider controls the new file name
|
||||
//
|
||||
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||
// with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - there is no documentation regarding support for usage with removable storage
|
||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||
try {
|
||||
val appDir = when {
|
||||
toBin -> StorageUtils.trashDirFor(activity, sourcePath ?: StorageUtils.getPrimaryVolumePath(activity))
|
||||
toVault -> File(targetDir)
|
||||
else -> null
|
||||
}
|
||||
if (appDir != null) {
|
||||
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
||||
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
||||
|
||||
if (toVault) {
|
||||
appDir.mkdirs()
|
||||
if (sourcePath != null) {
|
||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||
//
|
||||
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||
// - we need to scan the file to get the Media Store content URI
|
||||
// - the underlying document provider controls the new file name
|
||||
//
|
||||
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||
// with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - there is no documentation regarding support for usage with removable storage
|
||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||
try {
|
||||
val appDir = when {
|
||||
toBin -> StorageUtils.trashDirFor(activity, sourcePath)
|
||||
toVault -> File(targetDir)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (appDir != null) {
|
||||
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
||||
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
||||
|
||||
if (effectiveTargetDir != null) {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
||||
val sourceFile = if (sourcePath != null) File(sourcePath) else null
|
||||
if (sourceFile != null && !sourceFile.exists() && toBin) {
|
||||
delete(activity, sourceUri, sourcePath, mimeType = mimeType)
|
||||
deletedFieldMap
|
||||
} else {
|
||||
moveSingle(
|
||||
activity = activity,
|
||||
sourceFile = sourceFile,
|
||||
sourceUri = sourceUri,
|
||||
targetDir = effectiveTargetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
desiredName = desiredName ?: sourceFile?.name ?: sourceUri.lastPathSegment ?: createTimeStampFileName(),
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
toBin = toBin,
|
||||
)
|
||||
if (toVault) {
|
||||
appDir.mkdirs()
|
||||
}
|
||||
}
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
|
||||
if (effectiveTargetDir != null) {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
||||
val sourceFile = File(sourcePath)
|
||||
if (!sourceFile.exists() && toBin) {
|
||||
delete(activity, sourceUri, sourcePath, mimeType = mimeType)
|
||||
deletedFieldMap
|
||||
} else {
|
||||
moveSingle(
|
||||
activity = activity,
|
||||
sourceFile = sourceFile,
|
||||
sourceUri = sourceUri,
|
||||
targetDir = effectiveTargetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
desiredName = desiredName ?: sourceFile.name,
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
toBin = toBin,
|
||||
)
|
||||
}
|
||||
}
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
|
||||
}
|
||||
callback.onSuccess(result)
|
||||
}
|
||||
|
@ -546,7 +491,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
|
||||
private suspend fun moveSingle(
|
||||
activity: Activity,
|
||||
sourceFile: File?,
|
||||
sourceFile: File,
|
||||
sourceUri: Uri,
|
||||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
|
@ -556,24 +501,21 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
copy: Boolean,
|
||||
toBin: Boolean,
|
||||
): FieldMap {
|
||||
val sourcePath = sourceFile?.path
|
||||
val sourceExtension = sourceFile?.extension
|
||||
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
|
||||
val sourcePath = sourceFile.path
|
||||
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||
// nothing to do unless it's a renamed copy
|
||||
return skippedFieldMap
|
||||
}
|
||||
|
||||
val desiredNameWithoutExtension = desiredName.substringBeforeLast(".")
|
||||
val resolution = resolveTargetFileNameWithoutExtension(
|
||||
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
|
||||
contextWrapper = activity,
|
||||
dir = targetDir,
|
||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||
mimeType = mimeType,
|
||||
defaultExtension = sourceExtension,
|
||||
conflictStrategy = nameConflictStrategy,
|
||||
)
|
||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||
) ?: return skippedFieldMap
|
||||
|
||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||
val targetPath = createSingle(
|
||||
|
@ -582,7 +524,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||
defaultExtension = sourceExtension,
|
||||
) { output: OutputStream ->
|
||||
try {
|
||||
sourceDocFile.copyTo(output)
|
||||
|
@ -604,8 +545,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
return if (toBin) {
|
||||
hashMapOf(
|
||||
EntryFields.TRASHED to true,
|
||||
EntryFields.TRASH_PATH to targetPath,
|
||||
"trashed" to true,
|
||||
"trashPath" to targetPath,
|
||||
)
|
||||
} else {
|
||||
scanNewPath(activity, targetPath, mimeType)
|
||||
|
@ -618,23 +559,24 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
targetNameWithoutExtension: String,
|
||||
defaultExtension: String?,
|
||||
write: (OutputStream) -> Unit,
|
||||
): String {
|
||||
if (StorageUtils.isInVault(activity, targetDir)) {
|
||||
return insertByFile(
|
||||
targetDir = targetDir,
|
||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
||||
write = write,
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (isDownloadSubdir(activity, targetDir)) {
|
||||
val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir)
|
||||
val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath)
|
||||
if (isDownloadSubdir) {
|
||||
return insertByMediaStore(
|
||||
activity = activity,
|
||||
targetDir = targetDir,
|
||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
||||
write = write,
|
||||
)
|
||||
}
|
||||
|
@ -646,18 +588,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||
defaultExtension = defaultExtension,
|
||||
write = write,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isDownloadSubdir(context: Context, dir: String): Boolean {
|
||||
val volumePath = StorageUtils.getVolumePath(context, dir) ?: return false
|
||||
val downloadDirPath = ensureTrailingSeparator(File(volumePath, Environment.DIRECTORY_DOWNLOADS).path)
|
||||
// effective download path may have a different case
|
||||
return dir.lowercase().startsWith(downloadDirPath.lowercase())
|
||||
}
|
||||
|
||||
private fun insertByFile(
|
||||
targetDir: String,
|
||||
targetFileName: String,
|
||||
|
@ -705,7 +639,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
targetNameWithoutExtension: String,
|
||||
defaultExtension: String?,
|
||||
write: (OutputStream) -> Unit,
|
||||
): String {
|
||||
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
|
||||
|
@ -714,22 +647,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
var targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||
var targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
|
||||
// providing a display name and a MIME type does not guarantee
|
||||
// that the created document will be backed by a file with a valid media extension,
|
||||
// but having an extension is essential for media detection by Android,
|
||||
// so we retry with a display name that includes the extension
|
||||
if ((targetDocFile.extension == null || targetDocFile.extension.isEmpty() || targetDocFile.extension == "bin") && defaultExtension != null) {
|
||||
if (targetDocFile.exists()) {
|
||||
targetDocFile.delete()
|
||||
}
|
||||
|
||||
val extension = if (defaultExtension.startsWith(".")) defaultExtension else ".$defaultExtension"
|
||||
targetTreeFile = targetDirDocFile.createFile(mimeType, "$targetNameWithoutExtension$extension")
|
||||
targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
}
|
||||
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
|
||||
try {
|
||||
targetDocFile.openOutputStream().use(write)
|
||||
|
@ -846,32 +765,18 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return@scanFile
|
||||
}
|
||||
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||
callback.onSuccess(newFields)
|
||||
}
|
||||
}
|
||||
|
||||
// try to fetch the modified date from the file,
|
||||
// as it is more precise than the one from the Media Store
|
||||
private fun getFileModifiedDateMillis(path: String?): Long? {
|
||||
if (path != null) {
|
||||
try {
|
||||
return File(path).lastModified()
|
||||
} catch (securityException: SecurityException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) {
|
||||
val file = File(path)
|
||||
val delayMillis = 500L
|
||||
|
@ -949,15 +854,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val newFields = hashMapOf<String, Any?>(
|
||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||
EntryFields.URI to uri.toString(),
|
||||
EntryFields.CONTENT_ID to uri.tryParseId(),
|
||||
EntryFields.PATH to path,
|
||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||
"uri" to uri.toString(),
|
||||
"contentId" to uri.tryParseId(),
|
||||
"path" to path,
|
||||
)
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields[EntryFields.DATE_ADDED_SECS] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.close()
|
||||
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
||||
return newFields
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -1002,10 +906,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
try {
|
||||
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val idColumn = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||
if (idColumn != -1) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
mediaContentUri = ContentUris.withAppendedId(contentUri, id)
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns._ID).let {
|
||||
if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it))
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
@ -1068,4 +970,4 @@ object MediaColumns {
|
|||
|
||||
typealias NewEntryHandler = (entry: FieldMap) -> Unit
|
||||
|
||||
private typealias NewEntryChecker = (contentId: Long, dateModifiedMillis: Long) -> Boolean
|
||||
private typealias NewEntryChecker = (contentId: Int, dateModifiedSecs: Int) -> Boolean
|
|
@ -1,85 +0,0 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.model.EntryFields
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
||||
open class UnknownContentProvider : ImageProvider() {
|
||||
open val reliableProviderMimeType: Boolean
|
||||
get() = false
|
||||
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||
var mimeType = sourceMimeType
|
||||
if (sourceMimeType == null || !reliableProviderMimeType) {
|
||||
// source MIME type may be incorrect, so we get a second opinion if possible
|
||||
try {
|
||||
val safeUri = Uri.fromFile(Metadata.createPreviewFile(context, uri))
|
||||
StorageUtils.openInputStream(context, safeUri)?.use { input ->
|
||||
// `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives)
|
||||
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
|
||||
Helper.readMimeType(input)?.takeIf { it != MimeTypes.TIFF }?.let {
|
||||
if (it != sourceMimeType) {
|
||||
Log.d(LOG_TAG, "source MIME type is $sourceMimeType but extracted MIME type is $it for uri=$uri")
|
||||
mimeType = it
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
} catch (e: AssertionError) {
|
||||
Log.w(LOG_TAG, "failed to get MIME type by metadata-extractor for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
val fields: FieldMap = hashMapOf(
|
||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||
EntryFields.URI to uri.toString(),
|
||||
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||
)
|
||||
try {
|
||||
// some providers do not provide the mandatory `OpenableColumns`
|
||||
// and the query fails when compiling a projection specifying them
|
||||
// e.g. `content://mms/part/[id]` on Android KitKat
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields[EntryFields.TITLE] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields[EntryFields.PATH] = cursor.getString(it) }
|
||||
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields[EntryFields.SOURCE_MIME_TYPE] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(Exception("Failed to query content, error=${e.message}"))
|
||||
return
|
||||
}
|
||||
|
||||
if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
|
||||
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
||||
return
|
||||
}
|
||||
|
||||
val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
|
||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||
callback.onSuccess(entry.toMap())
|
||||
} else {
|
||||
callback.onFailure(Exception("entry has no size"))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<UnknownContentProvider>()
|
||||
}
|
||||
}
|
|
@ -2,121 +2,25 @@ package deckers.thibault.aves.utils
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ColorSpace
|
||||
import android.os.Build
|
||||
import android.util.Half
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object BitmapUtils {
|
||||
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
|
||||
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
|
||||
|
||||
// arbitrary size to detect buffer that may yield an OOM
|
||||
private const val BUFFER_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
||||
|
||||
private val freeBaos = ArrayList<ByteArrayOutputStream>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private const val INT_BYTE_SIZE = 4
|
||||
private const val MAX_2_BITS_FLOAT = 0x3.toFloat()
|
||||
private const val MAX_8_BITS_FLOAT = 0xff.toFloat()
|
||||
private const val MAX_10_BITS_FLOAT = 0x3ff.toFloat()
|
||||
|
||||
private const val RAW_BYTES_TRAILER_LENGTH = INT_BYTE_SIZE * 2
|
||||
|
||||
// bytes per pixel with different bitmap config
|
||||
private const val BPP_ALPHA_8 = 1
|
||||
private const val BPP_RGB_565 = 2
|
||||
private const val BPP_ARGB_8888 = 4
|
||||
private const val BPP_RGBA_1010102 = 4
|
||||
private const val BPP_RGBA_F16 = 8
|
||||
|
||||
private fun getBytePerPixel(config: Bitmap.Config?): Int {
|
||||
return when (config) {
|
||||
Bitmap.Config.ALPHA_8 -> BPP_ALPHA_8
|
||||
Bitmap.Config.RGB_565 -> BPP_RGB_565
|
||||
Bitmap.Config.ARGB_8888 -> BPP_ARGB_8888
|
||||
else -> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && config == Bitmap.Config.RGBA_F16) {
|
||||
BPP_RGBA_F16
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
BPP_RGBA_1010102
|
||||
} else {
|
||||
// default
|
||||
BPP_ARGB_8888
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getExpectedImageSize(pixelCount: Long, config: Bitmap.Config?): Long {
|
||||
return pixelCount * getBytePerPixel(config)
|
||||
}
|
||||
|
||||
fun getRawBytes(bitmap: Bitmap?, recycle: Boolean): ByteArray? {
|
||||
bitmap ?: return null
|
||||
|
||||
val byteCount = bitmap.byteCount
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val config = bitmap.config
|
||||
val colorSpace = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) bitmap.colorSpace else null
|
||||
|
||||
if (!MemoryUtils.canAllocate(byteCount)) {
|
||||
throw Exception("bitmap buffer is $byteCount bytes, which cannot be allocated to a new byte array")
|
||||
}
|
||||
|
||||
try {
|
||||
// `ByteBuffer` initial order is always `BIG_ENDIAN`
|
||||
var bytes = ByteBuffer.allocate(byteCount + RAW_BYTES_TRAILER_LENGTH).apply {
|
||||
bitmap.copyPixelsToBuffer(this)
|
||||
}.array()
|
||||
|
||||
// do not access bitmap after recycling
|
||||
if (recycle) bitmap.recycle()
|
||||
|
||||
// convert pixel format and color space, if necessary
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
colorSpace?.let { srcColorSpace ->
|
||||
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
|
||||
if (config == Bitmap.Config.ARGB_8888) {
|
||||
if (srcColorSpace != dstColorSpace) {
|
||||
argb8888ToArgb8888(bytes, connector, end = byteCount)
|
||||
}
|
||||
} else if (config == Bitmap.Config.RGBA_F16) {
|
||||
rgbaf16ToArgb8888(bytes, connector, end = byteCount)
|
||||
val newConfigByteCount = byteCount / (BPP_RGBA_F16 / BPP_ARGB_8888)
|
||||
bytes = bytes.sliceArray(0..<newConfigByteCount + RAW_BYTES_TRAILER_LENGTH)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
rgba1010102ToArgb8888(bytes, connector, end = byteCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// append bitmap size for use by the caller to interpret the raw bytes
|
||||
val trailerOffset = bytes.size - RAW_BYTES_TRAILER_LENGTH
|
||||
bytes = ByteBuffer.wrap(bytes).apply {
|
||||
position(trailerOffset)
|
||||
putInt(width)
|
||||
putInt(height)
|
||||
}.array()
|
||||
|
||||
return bytes
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getEncodedBytes(bitmap: Bitmap?, canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
|
||||
bitmap ?: return null
|
||||
|
||||
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
|
||||
val stream: ByteArrayOutputStream
|
||||
mutex.withLock {
|
||||
// this method is called a lot, so we try and reuse output streams
|
||||
|
@ -128,17 +32,19 @@ object BitmapUtils {
|
|||
}
|
||||
}
|
||||
try {
|
||||
// the Bitmap raw bytes are not decodable by Flutter
|
||||
// we need to format them (compress, or add a BMP header) before sending them
|
||||
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
|
||||
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
|
||||
if (canHaveAlpha && bitmap.hasAlpha()) {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||
if (canHaveAlpha && hasAlpha()) {
|
||||
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||
} else {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
}
|
||||
if (recycle) bitmap.recycle()
|
||||
if (recycle) this.recycle()
|
||||
|
||||
val bufferSize = stream.size()
|
||||
if (!MemoryUtils.canAllocate(bufferSize)) {
|
||||
if (bufferSize > BUFFER_SIZE_DANGER_THRESHOLD && !MemoryUtils.canAllocate(bufferSize)) {
|
||||
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
|
||||
}
|
||||
|
||||
|
@ -154,107 +60,6 @@ object BitmapUtils {
|
|||
return null
|
||||
}
|
||||
|
||||
// convert bytes, without reallocation:
|
||||
// - from original color space to sRGB.
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun argb8888ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||
// unpacking from ARGB_8888 and packing to ARGB_8888
|
||||
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||
for (i in start..<end step BPP_ARGB_8888) {
|
||||
// mask with `0xff` to yield values in [0, 255], instead of [-128, 127]
|
||||
val iB = bytes[i + 2].toInt() and 0xff
|
||||
val iG = bytes[i + 1].toInt() and 0xff
|
||||
val iR = bytes[i].toInt() and 0xff
|
||||
|
||||
// components as floats in sRGB
|
||||
val srgbFloats = connector.transform(iR / MAX_8_BITS_FLOAT, iG / MAX_8_BITS_FLOAT, iB / MAX_8_BITS_FLOAT)
|
||||
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||
|
||||
// keep alpha as it is, in `bytes[i + 3]`
|
||||
bytes[i + 2] = srgbB.toByte()
|
||||
bytes[i + 1] = srgbG.toByte()
|
||||
bytes[i] = srgbR.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
// convert bytes, without reallocation:
|
||||
// - from config RGBA_F16 to ARGB_8888,
|
||||
// - from original color space to sRGB.
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun rgbaf16ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||
val indexDivider = BPP_RGBA_F16 / BPP_ARGB_8888
|
||||
for (i in start..<end step BPP_RGBA_F16) {
|
||||
// unpacking from RGBA_F16
|
||||
// stored as [7,6,5,4,3,2,1,0] -> [AAAAAAAA AAAAAAAA BBBBBBBB BBBBBBBB GGGGGGGG GGGGGGGG RRRRRRRR RRRRRRRR]
|
||||
val i7 = bytes[i + 7].toInt()
|
||||
val i6 = bytes[i + 6].toInt()
|
||||
val i5 = bytes[i + 5].toInt()
|
||||
val i4 = bytes[i + 4].toInt()
|
||||
val i3 = bytes[i + 3].toInt()
|
||||
val i2 = bytes[i + 2].toInt()
|
||||
val i1 = bytes[i + 1].toInt()
|
||||
val i0 = bytes[i].toInt()
|
||||
|
||||
val hA = Half((((i7 and 0xff) shl 8) or (i6 and 0xff)).toShort())
|
||||
val hB = Half((((i5 and 0xff) shl 8) or (i4 and 0xff)).toShort())
|
||||
val hG = Half((((i3 and 0xff) shl 8) or (i2 and 0xff)).toShort())
|
||||
val hR = Half((((i1 and 0xff) shl 8) or (i0 and 0xff)).toShort())
|
||||
|
||||
// components as floats in sRGB
|
||||
val srgbFloats = connector.transform(hR.toFloat(), hG.toFloat(), hB.toFloat())
|
||||
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||
val alpha = (hA.toFloat() * 255.0f + 0.5f).toInt()
|
||||
|
||||
// packing to ARGB_8888
|
||||
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||
val dstI = i / indexDivider
|
||||
bytes[dstI + 3] = alpha.toByte()
|
||||
bytes[dstI + 2] = srgbB.toByte()
|
||||
bytes[dstI + 1] = srgbG.toByte()
|
||||
bytes[dstI] = srgbR.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
// convert bytes, without reallocation:
|
||||
// - from config RGBA_1010102 to ARGB_8888,
|
||||
// - from original color space to sRGB.
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun rgba1010102ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
|
||||
val alphaFactor = 255.0f / MAX_2_BITS_FLOAT
|
||||
|
||||
for (i in start..<end step BPP_RGBA_1010102) {
|
||||
// unpacking from RGBA_1010102
|
||||
// stored as [3,2,1,0] -> [AABBBBBB BBBBGGGG GGGGGGRR RRRRRRRR]
|
||||
val i3 = bytes[i + 3].toInt()
|
||||
val i2 = bytes[i + 2].toInt()
|
||||
val i1 = bytes[i + 1].toInt()
|
||||
val i0 = bytes[i].toInt()
|
||||
|
||||
val iA = ((i3 and 0xc0) shr 6)
|
||||
val iB = ((i3 and 0x3f) shl 4) or ((i2 and 0xf0) shr 4)
|
||||
val iG = ((i2 and 0x0f) shl 6) or ((i1 and 0xfc) shr 2)
|
||||
val iR = ((i1 and 0x03) shl 8) or ((i0 and 0xff) shr 0)
|
||||
|
||||
// components as floats in sRGB
|
||||
val srgbFloats = connector.transform(iR / MAX_10_BITS_FLOAT, iG / MAX_10_BITS_FLOAT, iB / MAX_10_BITS_FLOAT)
|
||||
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
|
||||
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
|
||||
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
|
||||
val alpha = (iA * alphaFactor + 0.5f).toInt()
|
||||
|
||||
// packing to ARGB_8888
|
||||
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
|
||||
bytes[i + 3] = alpha.toByte()
|
||||
bytes[i + 2] = srgbB.toByte()
|
||||
bytes[i + 1] = srgbG.toByte()
|
||||
bytes[i] = srgbR.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||
|
|
|
@ -90,7 +90,12 @@ object BmpWriter {
|
|||
|
||||
var column = 0
|
||||
while (column < biWidth) {
|
||||
// non-premultiplied ARGB values in the sRGB color space
|
||||
/*
|
||||
alpha: (value shr 24 and 0xFF).toByte()
|
||||
red: (value shr 16 and 0xFF).toByte()
|
||||
green: (value shr 8 and 0xFF).toByte()
|
||||
blue: (value and 0xFF).toByte()
|
||||
*/
|
||||
value = pixels[column]
|
||||
// blue: [0], green: [1], red: [2]
|
||||
rgb[0] = (value and 0xFF).toByte()
|
||||
|
|
|
@ -8,8 +8,6 @@ fun ByteBuffer.toByteArray(): ByteArray {
|
|||
return bytes
|
||||
}
|
||||
|
||||
fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
|
||||
|
||||
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
|
||||
|
||||
fun Byte.toHex(): String = "%02x".format(this)
|
|
@ -20,7 +20,6 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
|||
}
|
||||
|
||||
// Boyer-Moore algorithm for pattern searching
|
||||
// Returns: an index of the first occurrence of the pattern or -1 if none is found.
|
||||
fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int {
|
||||
val n: Int = this.size
|
||||
val m: Int = pattern.size
|
||||
|
|
|
@ -56,7 +56,13 @@ fun Geocoder.getFromLocationCompat(
|
|||
onError: (errorCode: String, errorMessage: String?, errorDetails: Any?) -> Unit,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Compat33.geocoderGetFromLocation(this, latitude, longitude, maxResults, processAddresses, onError)
|
||||
getFromLocation(latitude, longitude, maxResults, object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: List<Address?>) = processAddresses(addresses.filterNotNull())
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
onError("getAddress-asyncerror", "failed to get address", errorMessage)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
@Suppress("deprecation")
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.location.Address
|
||||
import android.location.Geocoder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
/**
|
||||
* Compatibility layer in a separate object to avoid class loading issues on older Android versions.
|
||||
* e.g. `ClassNotFoundException` for `android.location.Geocoder$GeocodeListener`
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
object Compat33 {
|
||||
fun geocoderGetFromLocation(
|
||||
geocoder: Geocoder,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
maxResults: Int,
|
||||
processAddresses: (addresses: List<Address>) -> Unit,
|
||||
onError: (errorCode: String, errorMessage: String?, errorDetails: Any?) -> Unit,
|
||||
) {
|
||||
geocoder.getFromLocation(latitude, longitude, maxResults, object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: List<Address?>) = processAddresses(addresses.filterNotNull())
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
onError("getAddress-asyncerror", "failed to get address", errorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
inline fun <reified T : Throwable> Exception.anyCauseIs(): Boolean {
|
||||
var cause: Throwable? = this
|
||||
while (cause != null) {
|
||||
if (cause is T) return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
@ -69,4 +71,29 @@ object FlutterUtils {
|
|||
r.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun Intent.enableSoftwareRendering() {
|
||||
putExtra("enable-software-rendering", true)
|
||||
Log.i(LOG_TAG, "Enable software rendering")
|
||||
}
|
||||
|
||||
fun isSoftwareRenderingRequired() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT && isEmulator
|
||||
|
||||
private val isEmulator: Boolean
|
||||
get() = (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|
||||
|| Build.FINGERPRINT.startsWith("generic")
|
||||
|| Build.FINGERPRINT.startsWith("unknown")
|
||||
|| Build.HARDWARE.contains("goldfish")
|
||||
|| Build.HARDWARE.contains("ranchu")
|
||||
|| Build.MODEL.contains("google_sdk")
|
||||
|| Build.MODEL.contains("Emulator")
|
||||
|| Build.MODEL.contains("Android SDK built for x86")
|
||||
|| Build.MANUFACTURER.contains("Genymotion")
|
||||
|| Build.PRODUCT.contains("sdk_google")
|
||||
|| Build.PRODUCT.contains("google_sdk")
|
||||
|| Build.PRODUCT.contains("sdk")
|
||||
|| Build.PRODUCT.contains("sdk_x86")
|
||||
|| Build.PRODUCT.contains("vbox86p")
|
||||
|| Build.PRODUCT.contains("emulator")
|
||||
|| Build.PRODUCT.contains("simulator"))
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue