Compare commits

..

16 commits

Author SHA1 Message Date
Thibault Deckers
5b6d7af0ac Merge branch 'develop' 2023-12-02 17:33:59 +01:00
Thibault Deckers
aa2b4c14e0 Merge branch 'develop' 2023-10-17 00:21:24 +02:00
Thibault Deckers
bf322c0aa9 Merge branch 'develop' 2023-09-25 00:38:11 +02:00
Thibault Deckers
1b87fb896d Merge branch 'develop' 2023-09-17 22:55:45 +02:00
Thibault Deckers
20f6d3f2a7 Merge branch 'develop' 2023-09-13 23:07:00 +02:00
Thibault Deckers
ffc6201e28 Merge branch 'develop' 2023-08-28 23:05:34 +02:00
Thibault Deckers
c4e06113b4 Merge branch 'develop' 2023-08-24 19:59:21 +02:00
Thibault Deckers
271809e189 Merge branch 'develop' 2023-08-22 23:43:28 +02:00
Thibault Deckers
fb8a97c5c6 Merge branch 'develop' 2023-08-21 00:20:29 +02:00
Thibault Deckers
cf64527c4b Merge branch 'develop' 2023-06-04 23:04:22 +02:00
Thibault Deckers
055d341a84 Merge branch 'develop' 2023-05-28 13:39:12 +02:00
Thibault Deckers
dc34fc6bc2 Merge branch 'develop' 2023-05-28 12:51:03 +02:00
Thibault Deckers
fa16430063 Merge branch 'develop' 2023-05-27 00:45:35 +02:00
Thibault Deckers
58e7adecde Merge branch 'develop' 2023-05-26 23:45:09 +02:00
Thibault Deckers
b8e8e3bfba Merge branch 'develop' 2023-05-26 23:16:42 +02:00
Thibault Deckers
79c5f5777b Merge branch 'develop' 2023-05-26 23:08:12 +02:00
1341 changed files with 27325 additions and 59548 deletions

@ -1 +1 @@
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
Subproject commit 9e1c857886f07d342cf106f2cd588bcd5e031bb2

View file

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily

26
.github/workflows/check.yml vendored Normal file
View 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

View file

@ -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

View file

@ -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}}"

View file

@ -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

View file

@ -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
View file

@ -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*

View file

@ -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
View file

@ -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"
}
]
}

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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'
}

View file

@ -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>

View file

@ -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" />

View file

@ -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"
}
}

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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,
)

View file

@ -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,

View file

@ -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>()
}
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -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"
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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,

View file

@ -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"
}

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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]"
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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"

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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?
}
}

View file

@ -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"
}
}

View file

@ -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)
}
}
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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<*> {

View file

@ -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
)

View file

@ -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)
}
}

View file

@ -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
}
}
}
}

View file

@ -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)"
}
}
}

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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
}
}
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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?)
}

View 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)

View file

@ -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"
}
}
}

View file

@ -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>()
}
}

View file

@ -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) {

View file

@ -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()

View file

@ -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
}

View file

@ -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

View file

@ -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>()
}
}

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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