Compare commits
33 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d813a61b9b | ||
![]() |
6d4c765613 | ||
![]() |
a396635639 | ||
![]() |
90fa60df69 | ||
![]() |
ce11587482 | ||
![]() |
07ac7b3fda | ||
![]() |
645c199b33 | ||
![]() |
ed75dba228 | ||
![]() |
2619613ee5 | ||
![]() |
82f070f8a1 | ||
![]() |
42a869908c | ||
![]() |
a02131c7c8 | ||
![]() |
579e7a2db0 | ||
![]() |
283a3eba60 | ||
![]() |
6f7f70babe | ||
![]() |
7cd170baf9 | ||
![]() |
32fff626d2 | ||
![]() |
5b6d7af0ac | ||
![]() |
aa2b4c14e0 | ||
![]() |
bf322c0aa9 | ||
![]() |
1b87fb896d | ||
![]() |
20f6d3f2a7 | ||
![]() |
ffc6201e28 | ||
![]() |
c4e06113b4 | ||
![]() |
271809e189 | ||
![]() |
fb8a97c5c6 | ||
![]() |
cf64527c4b | ||
![]() |
055d341a84 | ||
![]() |
dc34fc6bc2 | ||
![]() |
fa16430063 | ||
![]() |
58e7adecde | ||
![]() |
b8e8e3bfba | ||
![]() |
79c5f5777b |
1149 changed files with 15059 additions and 36665 deletions
2
.flutter
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
Subproject commit 761747bfc538b5af34aa0d3fac380f1bc331ec49
|
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
@ -1,6 +0,0 @@
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: daily
|
|
26
.github/workflows/check.yml
vendored
Normal file
26
.github/workflows/check.yml
vendored
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
name: Quality check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Check code quality.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone the repository.
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get packages for the Flutter project.
|
||||||
|
run: scripts/pub_get_all.sh
|
||||||
|
|
||||||
|
- name: Update the flutter version file.
|
||||||
|
run: scripts/update_flutter_version.sh
|
||||||
|
|
||||||
|
- name: Static analysis.
|
||||||
|
run: ./flutterw analyze
|
||||||
|
|
||||||
|
- name: Unit tests.
|
||||||
|
run: ./flutterw test
|
27
.github/workflows/dependency-review.yml
vendored
27
.github/workflows/dependency-review.yml
vendored
|
@ -1,27 +0,0 @@
|
||||||
# Dependency Review Action
|
|
||||||
#
|
|
||||||
# This Action will scan dependency manifest files that change as part of a Pull Request,
|
|
||||||
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
|
|
||||||
# Once installed, if the workflow run is marked as required,
|
|
||||||
# PRs introducing known-vulnerable packages will be blocked from merging.
|
|
||||||
#
|
|
||||||
# Source repository: https://github.com/actions/dependency-review-action
|
|
||||||
name: 'Dependency Review'
|
|
||||||
on: [pull_request]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dependency-review:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden Runner
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: 'Checkout Repository'
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
- name: 'Dependency Review'
|
|
||||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
|
91
.github/workflows/quality-check.yml
vendored
91
.github/workflows/quality-check.yml
vendored
|
@ -1,91 +0,0 @@
|
||||||
name: Quality check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "develop", "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "develop", "main" ]
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
schedule:
|
|
||||||
- cron: '17 8 * * 3'
|
|
||||||
|
|
||||||
# Declare default permissions as read only.
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze_flutter:
|
|
||||||
name: Flutter analysis
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden Runner
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Get Flutter packages
|
|
||||||
run: ./flutterw pub get
|
|
||||||
|
|
||||||
- name: Generate app localizations
|
|
||||||
run: ./flutterw gen-l10n
|
|
||||||
|
|
||||||
- name: Static analysis.
|
|
||||||
run: ./flutterw analyze
|
|
||||||
|
|
||||||
- name: Unit tests.
|
|
||||||
run: ./flutterw test
|
|
||||||
|
|
||||||
analyze_codeql:
|
|
||||||
name: CodeQL analysis (${{ matrix.language }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
# required for all workflows
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
# required to fetch internal or private CodeQL packs
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- language: java-kotlin
|
|
||||||
build-mode: manual
|
|
||||||
steps:
|
|
||||||
- name: Harden Runner
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
|
||||||
# which requires a modern Java version (not the default one).
|
|
||||||
- name: Set up JDK for Android Gradle plugin
|
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '21'
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
build-mode: ${{ matrix.build-mode }}
|
|
||||||
|
|
||||||
- if: matrix.build-mode == 'manual'
|
|
||||||
shell: bash
|
|
||||||
# build in profile mode, instead of release,
|
|
||||||
# so that setting up signing environment variables is not required
|
|
||||||
run: |
|
|
||||||
scripts/apply_flavor_play.sh
|
|
||||||
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
|
||||||
with:
|
|
||||||
category: "/language:${{matrix.language}}"
|
|
81
.github/workflows/release.yml
vendored
81
.github/workflows/release.yml
vendored
|
@ -5,44 +5,32 @@ on:
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
|
||||||
# Declare default permissions as read only.
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release_github:
|
build:
|
||||||
name: GitHub release
|
name: Build and release artifacts.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
attestations: write
|
|
||||||
contents: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- uses: actions/setup-java@v4
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
- name: Clone the repository.
|
||||||
# which requires a modern Java version (not the default one).
|
uses: actions/checkout@v4
|
||||||
- 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
|
- name: Get packages for the Flutter project.
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
run: scripts/pub_get_all.sh
|
||||||
|
|
||||||
- name: Get Flutter packages
|
- name: Update the flutter version file.
|
||||||
run: ./flutterw pub get
|
|
||||||
|
|
||||||
- name: Generate app localizations
|
|
||||||
run: ./flutterw gen-l10n
|
|
||||||
|
|
||||||
- name: Update Flutter version file
|
|
||||||
run: scripts/update_flutter_version.sh
|
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:
|
# `KEY_JKS` should contain the result of:
|
||||||
# gpg -c --armor keystore.jks
|
# gpg -c --armor keystore.jks
|
||||||
# `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above
|
# `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above
|
||||||
|
@ -65,8 +53,6 @@ jobs:
|
||||||
./flutterw build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi
|
./flutterw build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi
|
||||||
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
||||||
scripts/apply_flavor_libre.sh
|
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
|
./flutterw build apk -t lib/main_libre.dart --flavor libre --split-per-abi
|
||||||
cp build/app/outputs/apk/libre/release/*.apk outputs
|
cp build/app/outputs/apk/libre/release/*.apk outputs
|
||||||
rm $AVES_STORE_FILE
|
rm $AVES_STORE_FILE
|
||||||
|
@ -77,44 +63,33 @@ jobs:
|
||||||
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
|
AVES_KEY_PASSWORD: ${{ secrets.AVES_KEY_PASSWORD }}
|
||||||
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Create a release with the APK and App Bundle.
|
||||||
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
|
||||||
subject-path: 'outputs/*'
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
|
||||||
with:
|
with:
|
||||||
artifacts: "outputs/*"
|
artifacts: "outputs/*"
|
||||||
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload app bundle
|
- name: Upload app bundle
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: appbundle
|
name: appbundle
|
||||||
path: outputs/app-play-release.aab
|
path: outputs/app-play-release.aab
|
||||||
|
|
||||||
release_play:
|
release:
|
||||||
name: Play Store beta release
|
name: Create beta release on Play Store.
|
||||||
needs: [ release_github ]
|
needs: [ build ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- uses: actions/checkout@v4
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Get appbundle from artifacts.
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
- name: Get appbundle from artifacts
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
||||||
with:
|
with:
|
||||||
name: appbundle
|
name: appbundle
|
||||||
|
|
||||||
- name: Release to beta channel
|
- name: Release app to beta channel.
|
||||||
uses: r0adkll/upload-google-play@935ef9c68bb393a8e6116b1575626a7f5be3a7fb # v1.1.3
|
uses: r0adkll/upload-google-play@v1.1.3
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
|
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
|
||||||
packageName: deckers.thibault.aves
|
packageName: deckers.thibault.aves
|
||||||
|
|
76
.github/workflows/scorecards.yml
vendored
76
.github/workflows/scorecards.yml
vendored
|
@ -1,76 +0,0 @@
|
||||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
|
||||||
# by a third-party and are governed by separate terms of service, privacy
|
|
||||||
# policy, and support documentation.
|
|
||||||
|
|
||||||
name: Scorecard supply-chain security
|
|
||||||
on:
|
|
||||||
# For Branch-Protection check. Only the default branch is supported. See
|
|
||||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
|
||||||
branch_protection_rule:
|
|
||||||
# To guarantee Maintained check is occasionally updated. See
|
|
||||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
|
||||||
schedule:
|
|
||||||
- cron: '20 7 * * 2'
|
|
||||||
push:
|
|
||||||
branches: ["develop"]
|
|
||||||
|
|
||||||
# Declare default permissions as read only.
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analysis:
|
|
||||||
name: Scorecard analysis
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
# Needed to upload the results to code-scanning dashboard.
|
|
||||||
security-events: write
|
|
||||||
# Needed to publish results and get a badge (see publish_results below).
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Harden Runner
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: "Checkout code"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: "Run analysis"
|
|
||||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
|
||||||
with:
|
|
||||||
results_file: results.sarif
|
|
||||||
results_format: sarif
|
|
||||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
|
||||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
|
||||||
# - you are installing Scorecards on a *private* repository
|
|
||||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
|
|
||||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
|
||||||
|
|
||||||
# Public repositories:
|
|
||||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
|
||||||
# - Allows the repository to include the Scorecard badge.
|
|
||||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
|
||||||
# For private repositories:
|
|
||||||
# - `publish_results` will always be set to `false`, regardless
|
|
||||||
# of the value entered here.
|
|
||||||
publish_results: true
|
|
||||||
|
|
||||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
|
||||||
# format to the repository Actions tab.
|
|
||||||
- name: "Upload artifact"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: SARIF file
|
|
||||||
path: results.sarif
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
# Upload the results to GitHub's code scanning dashboard.
|
|
||||||
- name: "Upload to code-scanning"
|
|
||||||
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
|
||||||
with:
|
|
||||||
sarif_file: results.sarif
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -5,11 +5,9 @@
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
.build/
|
|
||||||
.buildlog/
|
.buildlog/
|
||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
|
@ -29,6 +27,7 @@ migrate_working_dir/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
@ -47,6 +46,3 @@ app.*.map.json
|
||||||
# screenshot generation
|
# screenshot generation
|
||||||
/test_driver/assets/screenshots/
|
/test_driver/assets/screenshots/
|
||||||
/screenshots/
|
/screenshots/
|
||||||
|
|
||||||
# generated files
|
|
||||||
/lib/l10ngen/app_localizations*
|
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
repos:
|
|
||||||
- repo: https://github.com/gherynos/pre-commit-java
|
|
||||||
rev: v0.2.4
|
|
||||||
hooks:
|
|
||||||
- id: Checkstyle
|
|
||||||
- repo: https://github.com/gitleaks/gitleaks
|
|
||||||
rev: v8.16.3
|
|
||||||
hooks:
|
|
||||||
- id: gitleaks
|
|
||||||
- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
|
||||||
rev: 3.0.0
|
|
||||||
hooks:
|
|
||||||
- id: shellcheck
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.4.0
|
|
||||||
hooks:
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: trailing-whitespace
|
|
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "aves (main play)",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "dart",
|
|
||||||
"program": "lib/main_play.dart",
|
|
||||||
"args": [
|
|
||||||
"--flavor",
|
|
||||||
"play"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "aves (main play) [profile]",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "dart",
|
|
||||||
"program": "lib/main_play.dart",
|
|
||||||
"args": [
|
|
||||||
"--flavor",
|
|
||||||
"play"
|
|
||||||
],
|
|
||||||
"flutterMode": "profile"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
335
CHANGELOG.md
335
CHANGELOG.md
|
@ -4,352 +4,22 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## <a id="unreleased"></a>[Unreleased]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
### Added
|
## <a id="v1.11.6"></a>[v1.11.6] - 2024-07-17
|
||||||
|
|
||||||
- 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
|
### Added
|
||||||
|
|
||||||
- Explorer: set custom path as home
|
- Explorer: set custom path as home
|
||||||
- Explorer: create shortcut to custom path
|
- Explorer: create shortcut to custom path
|
||||||
|
- predictive back support (inter-app)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- target Android 15 (API 35)
|
- target Android 15 (API 35)
|
||||||
- upgraded Flutter to stable v3.22.3
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- crash when cataloguing some PNG files
|
- 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
|
## <a id="v1.11.5"></a>[v1.11.5] - 2024-07-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -1397,7 +1067,6 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- app launching on some devices
|
- app launching on some devices
|
||||||
- corrupting motion photo exif editing (e.g. rotation)
|
- corrupting motion photo exif editing (e.g. rotation)
|
||||||
- accessing files in `Download` directory when not using reference case
|
|
||||||
|
|
||||||
## [v1.4.9] - 2021-08-20
|
## [v1.4.9] - 2021-08-20
|
||||||
|
|
||||||
|
|
89
README.md
89
README.md
|
@ -35,7 +35,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.
|
**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 14, 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
|
## Screenshots
|
||||||
|
|
||||||
|
@ -111,96 +111,17 @@ Some users have expressed the wish to financially support the project. Thanks!
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
### Install dependencies
|
|
||||||
|
|
||||||
Before running or building the app, update the dependencies for the desired flavor:
|
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 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
|
# ./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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
[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
|
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,6 @@ analyzer:
|
||||||
# implicit-casts: false
|
# implicit-casts: false
|
||||||
# implicit-dynamic: false
|
# implicit-dynamic: false
|
||||||
|
|
||||||
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
|
|
||||||
formatter:
|
|
||||||
page_width: 240
|
|
||||||
trailing_commas: preserve
|
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
# from 'flutter_lints', excluded
|
# from 'flutter_lints', excluded
|
||||||
|
@ -41,8 +36,3 @@ linter:
|
||||||
prefer_single_quotes: true
|
prefer_single_quotes: true
|
||||||
sort_child_properties_last: true
|
sort_child_properties_last: true
|
||||||
unawaited_futures: true
|
unawaited_futures: true
|
||||||
|
|
||||||
# `const` related, included
|
|
||||||
prefer_const_constructors: true
|
|
||||||
prefer_const_literals_to_create_immutables: true
|
|
||||||
prefer_const_declarations: true
|
|
||||||
|
|
5
android/.gitignore
vendored
5
android/.gitignore
vendored
|
@ -5,12 +5,9 @@ gradle-wrapper.jar
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
.cxx/
|
|
||||||
.kotlin/
|
|
||||||
/build/
|
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# 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
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
|
@ -8,6 +10,18 @@ plugins {
|
||||||
|
|
||||||
def packageName = "deckers.thibault.aves"
|
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')
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
def keystoreProperties = new Properties()
|
||||||
|
@ -28,20 +42,33 @@ if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
|
keystoreProperties["googleApiKey"] = System.getenv("AVES_GOOGLE_API_KEY") ?: "<NONE>"
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain 17
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = 'deckers.thibault.aves'
|
namespace 'deckers.thibault.aves'
|
||||||
compileSdk = 36
|
compileSdk 35
|
||||||
|
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
|
||||||
|
ndkVersion '26.1.10909125'
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
checkAllWarnings true
|
||||||
|
warningsAsErrors true
|
||||||
|
disable 'InvalidPackage'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId packageName
|
applicationId packageName
|
||||||
minSdk flutter.minSdkVersion
|
minSdk flutter.minSdkVersion
|
||||||
targetSdk 36
|
targetSdk 35
|
||||||
versionCode flutter.versionCode
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutter.versionName
|
versionName flutterVersionName
|
||||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
@ -128,20 +155,29 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(KotlinCompile).configureEach {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
maven {
|
||||||
url = 'https://jitpack.io'
|
url 'https://jitpack.io'
|
||||||
content {
|
content {
|
||||||
includeGroup "com.github.deckerst"
|
includeGroup "com.github.deckerst"
|
||||||
includeGroup "com.github.deckerst.mp4parser"
|
includeGroup "com.github.deckerst.mp4parser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maven {
|
maven {
|
||||||
url = 'https://s3.amazonaws.com/repo.commonsware.com'
|
url 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||||
content {
|
content {
|
||||||
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
||||||
}
|
}
|
||||||
|
@ -149,38 +185,37 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
|
|
||||||
implementation "androidx.appcompat:appcompat:1.7.1"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation 'androidx.core:core-ktx:1.16.0'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.9.1'
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.3'
|
||||||
implementation 'androidx.media:media:1.7.0'
|
implementation 'androidx.media:media:1.7.0'
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
implementation 'androidx.security:security-crypto:1.1.0-beta01'
|
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
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.commonsware.cwac:document:0.5.0'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
// SLF4J implementation for `mp4parser`
|
// SLF4J implementation for `mp4parser`
|
||||||
implementation 'org.slf4j:slf4j-simple:2.0.17'
|
implementation 'org.slf4j:slf4j-simple:2.0.13'
|
||||||
|
|
||||||
// forked, built by JitPack:
|
// forked, built by JitPack:
|
||||||
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
// - https://jitpack.io/p/deckerst/androidsvg
|
|
||||||
// - https://jitpack.io/p/deckerst/mp4parser
|
// - https://jitpack.io/p/deckerst/mp4parser
|
||||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:d6b2b0aa4f'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4'
|
||||||
implementation 'com.github.deckerst:androidsvg:67db933051'
|
implementation 'com.github.deckerst.mp4parser:isoparser:4cc0c5d06c'
|
||||||
implementation 'com.github.deckerst.mp4parser:isoparser:c2898f1832'
|
implementation 'com.github.deckerst.mp4parser:muxer:4cc0c5d06c'
|
||||||
implementation 'com.github.deckerst.mp4parser:muxer:c2898f1832'
|
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17'
|
||||||
implementation 'com.github.deckerst:pixymeta-android:cb1cdc932e'
|
|
||||||
implementation project(':exifinterface')
|
implementation project(':exifinterface')
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.9.1'
|
kapt 'androidx.annotation:annotation:1.8.0'
|
||||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
|
||||||
compileOnly rootProject.findProject(':streams_channel')
|
compileOnly rootProject.findProject(':streams_channel')
|
||||||
|
|
|
@ -76,7 +76,7 @@
|
||||||
-->
|
-->
|
||||||
<uses-sdk tools:overrideLibrary="com.arthenica.ffmpegkit.flutter" />
|
<uses-sdk tools:overrideLibrary="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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
@ -92,7 +92,7 @@
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent>
|
</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=
|
if appropriate intents are not declared, cf https://pub.dev/packages/url_launcher#configuration=
|
||||||
-->
|
-->
|
||||||
<!-- to open https URLs -->
|
<!-- to open https URLs -->
|
||||||
|
@ -102,23 +102,19 @@
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<!--
|
|
||||||
as of Flutter v3.22.2, predictive back gesture does not work
|
|
||||||
as expected when extending `FlutterFragmentActivity`
|
|
||||||
so we disable `enableOnBackInvokedCallback`
|
|
||||||
-->
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:appCategory="image"
|
android:appCategory="image"
|
||||||
android:banner="@drawable/banner"
|
android:banner="@drawable/banner"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:enableOnBackInvokedCallback="false"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:fullBackupContent="@xml/full_backup_content"
|
android:fullBackupContent="@xml/full_backup_content"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
tools:targetApi="tiramisu">
|
tools:targetApi="tiramisu">
|
||||||
<activity
|
<activity
|
||||||
|
@ -179,13 +175,6 @@
|
||||||
<data android:scheme="content" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" />
|
<data android:scheme="file" />
|
||||||
</intent-filter>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EDIT" />
|
<action android:name="android.intent.action.EDIT" />
|
||||||
|
@ -329,6 +318,8 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<!-- as of Flutter v3.22.0 (stable),
|
||||||
|
Impeller fails to render videos (via `ffmpegkit`), and has random glitches -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="false" />
|
||||||
|
|
|
@ -14,7 +14,6 @@ import androidx.work.ForegroundInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import app.loup.streams_channel.StreamsChannel
|
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.DeviceHandler
|
||||||
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
import deckers.thibault.aves.channel.calls.GeocodingHandler
|
||||||
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||||
|
@ -45,12 +44,11 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
private var backgroundChannel: MethodChannel? = null
|
private var backgroundChannel: MethodChannel? = null
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Log.i(LOG_TAG, "Start analysis worker $id")
|
|
||||||
defaultScope.launch {
|
defaultScope.launch {
|
||||||
// prevent ANR triggered by slow operations in main thread
|
// prevent ANR triggered by slow operations in main thread
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo())
|
setForeground(createForegroundInfo())
|
||||||
}.join()
|
}
|
||||||
suspendCoroutine { cont ->
|
suspendCoroutine { cont ->
|
||||||
workCont = cont
|
workCont = cont
|
||||||
onStart()
|
onStart()
|
||||||
|
@ -70,6 +68,7 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onStart() {
|
private fun onStart() {
|
||||||
|
Log.i(LOG_TAG, "Start analysis worker $id")
|
||||||
runBlocking {
|
runBlocking {
|
||||||
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
|
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
|
||||||
flutterEngine = it
|
flutterEngine = it
|
||||||
|
@ -133,7 +132,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
result.success(null)
|
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" -> {
|
"stop" -> {
|
||||||
workCont?.resume(null)
|
workCont?.resume(null)
|
||||||
|
@ -176,22 +180,17 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
.setContentIntent(openAppIntent)
|
.setContentIntent(openAppIntent)
|
||||||
.addAction(stopAction)
|
.addAction(stopAction)
|
||||||
.build()
|
.build()
|
||||||
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
|
return if (Build.VERSION.SDK_INT == 34) {
|
||||||
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
|
||||||
return when {
|
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
||||||
Build.VERSION.SDK_INT >= 35 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
|
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
Build.VERSION.SDK_INT == 34 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
} else if (Build.VERSION.SDK_INT >= 35) {
|
||||||
else -> ForegroundInfo(NOTIFICATION_ID, notification)
|
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
|
||||||
|
} 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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
|
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
|
||||||
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
|
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
|
||||||
|
|
|
@ -8,22 +8,15 @@ import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.SizeF
|
import android.util.SizeF
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.*
|
||||||
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.streams.ImageByteStreamHandler
|
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||||
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -33,14 +26,8 @@ import io.flutter.FlutterInjector
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.coroutines.Continuation
|
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
@ -84,7 +71,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
||||||
|
|
||||||
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
|
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<FieldMap> {
|
||||||
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
@ -103,7 +90,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sizes
|
return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getProps(
|
private suspend fun getProps(
|
||||||
|
@ -117,30 +104,41 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
if (sizesDip.isEmpty()) return null
|
if (sizesDip.isEmpty()) return null
|
||||||
|
|
||||||
val sizeDip = sizesDip.first()
|
val sizeDip = sizesDip.first()
|
||||||
if (sizeDip.width == 0f || sizeDip.height == 0f) return null
|
if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) 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 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initFlutterEngine(context)
|
initFlutterEngine(context)
|
||||||
|
val messenger = flutterEngine!!.dartExecutor
|
||||||
|
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
|
||||||
try {
|
try {
|
||||||
val props = suspendCoroutine { cont ->
|
val props = suspendCoroutine<Any?> { cont ->
|
||||||
defaultScope.launch {
|
defaultScope.launch {
|
||||||
FlutterUtils.runOnUiThread {
|
FlutterUtils.runOnUiThread {
|
||||||
tryDrawWidget(params, cont, 0)
|
channel.invokeMethod("drawWidget", hashMapOf(
|
||||||
|
"widgetId" to widgetId,
|
||||||
|
"sizesDip" to sizesDip,
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
}, 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"))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -152,30 +150,6 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
return null
|
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(
|
private fun updateWidgetImage(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
@ -219,7 +193,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
|
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
|
val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also {
|
||||||
bitmaps.add(it)
|
bitmaps.add(it)
|
||||||
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
|
||||||
}
|
}
|
||||||
|
@ -261,7 +235,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
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))
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
||||||
|
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
|
@ -278,7 +252,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
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
|
// 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)
|
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
||||||
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
|
@ -297,7 +271,6 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
|
private val LOG_TAG = LogUtils.createTag<HomeWidgetProvider>()
|
||||||
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
|
private const val WIDGET_DART_ENTRYPOINT = "widgetMain"
|
||||||
private const val WIDGET_DRAW_CHANNEL = "deckers.thibault/aves/widget_draw"
|
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 flutterEngine: FlutterEngine? = null
|
||||||
private var imageByteFetchJob: Job? = null
|
private var imageByteFetchJob: Job? = null
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
package deckers.thibault.aves
|
package deckers.thibault.aves
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.KeyguardManager
|
|
||||||
import android.app.SearchManager
|
import android.app.SearchManager
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -19,13 +17,11 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
import deckers.thibault.aves.channel.calls.AnalysisHandler
|
import deckers.thibault.aves.channel.calls.AnalysisHandler
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
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.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.DebugHandler
|
import deckers.thibault.aves.channel.calls.DebugHandler
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
@ -57,9 +53,8 @@ import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||||
import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
|
import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.anyCauseIs
|
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -71,8 +66,7 @@ import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
// `FlutterFragmentActivity` because of local auth plugin
|
open class MainActivity : FlutterActivity() {
|
||||||
open class MainActivity : FlutterFragmentActivity() {
|
|
||||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
||||||
|
@ -146,7 +140,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||||
// - need Activity
|
// - need Activity
|
||||||
MethodChannel(messenger, AppProfileHandler.CHANNEL).setMethodCallHandler(AppProfileHandler(this))
|
|
||||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
||||||
|
|
||||||
// result streaming: dart -> platform ->->-> dart
|
// result streaming: dart -> platform ->->-> dart
|
||||||
|
@ -235,7 +228,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
when (requestCode) {
|
when (requestCode) {
|
||||||
|
@ -298,11 +290,14 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
open fun extractIntentData(intent: Intent?): FieldMap {
|
open fun extractIntentData(intent: Intent?): FieldMap {
|
||||||
when (val action = intent?.action) {
|
when (val action = intent?.action) {
|
||||||
Intent.ACTION_MAIN -> {
|
Intent.ACTION_MAIN -> {
|
||||||
return hashMapOf(
|
val fields = HashMap<String, Any?>()
|
||||||
INTENT_DATA_KEY_PAGE to intent.getStringExtra(EXTRA_KEY_PAGE),
|
if (intent.getBooleanExtra(EXTRA_KEY_SAFE_MODE, false)) {
|
||||||
INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent),
|
fields[INTENT_DATA_KEY_SAFE_MODE] = true
|
||||||
INTENT_DATA_KEY_EXPLORER_PATH to intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH),
|
}
|
||||||
)
|
fields[INTENT_DATA_KEY_PAGE] = intent.getStringExtra(EXTRA_KEY_PAGE)
|
||||||
|
fields[INTENT_DATA_KEY_FILTERS] = extractFiltersFromIntent(intent)
|
||||||
|
fields[INTENT_DATA_KEY_EXPLORER_PATH] = intent.getStringExtra(EXTRA_KEY_EXPLORER_PATH)
|
||||||
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
|
@ -312,14 +307,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
"com.android.camera.action.REVIEW",
|
"com.android.camera.action.REVIEW",
|
||||||
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
|
"com.android.camera.action.SPLIT_SCREEN_REVIEW" -> {
|
||||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
(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
|
// MIME type is optional
|
||||||
val type = intent.type ?: intent.resolveType(this)
|
val type = intent.type ?: intent.resolveType(this)
|
||||||
val fields = hashMapOf<String, Any?>(
|
val fields = hashMapOf<String, Any?>(
|
||||||
|
@ -328,16 +315,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
INTENT_DATA_KEY_URI to uri.toString(),
|
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) {
|
if (action == MediaStore.ACTION_REVIEW_SECURE) {
|
||||||
val uris = ArrayList<String>()
|
val uris = ArrayList<String>()
|
||||||
intent.clipData?.let { clipData ->
|
intent.clipData?.let { clipData ->
|
||||||
|
@ -345,9 +322,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
clipData.getItemAt(i).uri?.let { uris.add(it.toString()) }
|
clipData.getItemAt(i).uri?.let { uris.add(it.toString()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uris.isNotEmpty()) {
|
fields[INTENT_DATA_KEY_SECURE_URIS] = uris
|
||||||
fields[INTENT_DATA_KEY_SECURE_URIS] = uris
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && intent.hasExtra(MediaStore.EXTRA_BRIGHTNESS)) {
|
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)
|
fields[INTENT_DATA_KEY_BRIGHTNESS] = intent.getFloatExtra(MediaStore.EXTRA_BRIGHTNESS, 0f)
|
||||||
|
@ -373,8 +348,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK_ITEMS,
|
||||||
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
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.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false),
|
||||||
INTENT_DATA_KEY_ALLOW_MULTIPLE to intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -436,50 +410,32 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
||||||
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
try {
|
||||||
setResult(RESULT_OK, intent)
|
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)
|
||||||
|
}
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
|
} else {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
}
|
||||||
finish()
|
finish()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
setResult(RESULT_CANCELED)
|
if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) {
|
||||||
if (e is SecurityException && intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) {
|
result.error("submitPickedItems-large", "transaction too large with ${pickedUris?.size} URIs", e)
|
||||||
// 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 {
|
} else {
|
||||||
result.error("submitPickedItems-exception", "failed to pick ${intent.clipData?.itemCount} URIs", e)
|
result.error("submitPickedItems-exception", "failed to pick ${pickedUris?.size} URIs", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -510,16 +466,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
.putExtra(EXTRA_KEY_PAGE, SEARCH_PAGE_ROUTE_NAME)
|
.putExtra(EXTRA_KEY_PAGE, "/search")
|
||||||
)
|
|
||||||
.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)
|
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@ -528,12 +475,21 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
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/*\"}"))
|
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||||
)
|
)
|
||||||
.build()
|
.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)
|
ShortcutManagerCompat.setDynamicShortcuts(this, shortcutInfoList)
|
||||||
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
|
Log.i(LOG_TAG, "set shortcuts: ${shortcutInfoList.joinToString(", ") { v -> v.id }}")
|
||||||
}
|
}
|
||||||
|
@ -563,7 +519,6 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val INTENT_ACTION_SEARCH = "search"
|
const val INTENT_ACTION_SEARCH = "search"
|
||||||
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
const val INTENT_ACTION_SET_WALLPAPER = "set_wallpaper"
|
||||||
const val INTENT_ACTION_VIEW = "view"
|
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_OPEN = "widget_open"
|
||||||
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
|
const val INTENT_ACTION_WIDGET_SETTINGS = "widget_settings"
|
||||||
|
|
||||||
|
@ -573,9 +528,9 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
|
const val INTENT_DATA_KEY_EXPLORER_PATH = "explorerPath"
|
||||||
const val INTENT_DATA_KEY_FILTERS = "filters"
|
const val INTENT_DATA_KEY_FILTERS = "filters"
|
||||||
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
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_PAGE = "page"
|
||||||
const val INTENT_DATA_KEY_QUERY = "query"
|
const val INTENT_DATA_KEY_QUERY = "query"
|
||||||
|
const val INTENT_DATA_KEY_SAFE_MODE = "safeMode"
|
||||||
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
|
const val INTENT_DATA_KEY_SECURE_URIS = "secureUris"
|
||||||
const val INTENT_DATA_KEY_URI = "uri"
|
const val INTENT_DATA_KEY_URI = "uri"
|
||||||
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
|
const val INTENT_DATA_KEY_WIDGET_ID = "widgetId"
|
||||||
|
@ -584,15 +539,9 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
|
const val EXTRA_KEY_EXPLORER_PATH = "explorerPath"
|
||||||
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
|
const val EXTRA_KEY_FILTERS_ARRAY = "filters"
|
||||||
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
|
const val EXTRA_KEY_FILTERS_STRING = "filtersString"
|
||||||
|
const val EXTRA_KEY_SAFE_MODE = "safeMode"
|
||||||
const val EXTRA_KEY_WIDGET_ID = "widgetId"
|
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
|
// request code to pending runnable
|
||||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||||
|
|
||||||
|
|
|
@ -5,15 +5,7 @@ import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
import deckers.thibault.aves.channel.calls.*
|
||||||
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.window.ServiceWindowHandler
|
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||||
|
|
|
@ -16,12 +16,8 @@ import deckers.thibault.aves.utils.FlutterUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import java.util.*
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
|
@ -2,7 +2,6 @@ package deckers.thibault.aves
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
|
@ -40,7 +39,7 @@ class WallpaperActivity : MainActivity() {
|
||||||
if (originalIntent != null) {
|
if (originalIntent != null) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
val pickedUris = call.argument<List<String>>("uris")
|
||||||
if (!pickedUris.isNullOrEmpty()) {
|
if (!pickedUris.isNullOrEmpty()) {
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
||||||
onNewIntent(Intent().apply {
|
onNewIntent(Intent().apply {
|
||||||
action = originalIntent
|
action = originalIntent
|
||||||
data = toUri(pickedUris.first())
|
data = toUri(pickedUris.first())
|
||||||
|
|
|
@ -21,28 +21,27 @@ class AvesByteSendingMethodCodec private constructor() : MethodCodec {
|
||||||
return STANDARD.encodeMethodCall(methodCall)
|
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 {
|
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
|
||||||
if (result is ByteArray) {
|
if (result is ByteArray) {
|
||||||
return ByteBuffer.allocateDirect(1 + result.size).apply {
|
val size = result.size
|
||||||
// following `StandardMethodCodec`:
|
return ByteBuffer.allocateDirect(4 + size).apply {
|
||||||
// First byte is zero in success case, and non-zero otherwise.
|
|
||||||
put(0)
|
put(0)
|
||||||
put(result)
|
put(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$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 {
|
companion object {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewConfiguration
|
|
||||||
import android.view.accessibility.AccessibilityManager
|
import android.view.accessibility.AccessibilityManager
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
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) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||||
"getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout)
|
|
||||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||||
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
"shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont)
|
||||||
|
@ -36,10 +34,6 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC
|
||||||
result.success(removed)
|
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) {
|
private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.app.ActivityManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
|
@ -10,7 +8,7 @@ import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import deckers.thibault.aves.AnalysisWorker
|
import deckers.thibault.aves.AnalysisWorker
|
||||||
import deckers.thibault.aves.utils.FlutterUtils
|
import deckers.thibault.aves.utils.FlutterUtils
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -19,7 +17,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class AnalysisHandler(private val activity: FlutterFragmentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
|
||||||
|
class AnalysisHandler(private val activity: FlutterActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -38,8 +37,9 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
preferences.edit {
|
with(preferences.edit()) {
|
||||||
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
|
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
|
||||||
|
apply()
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
@ -51,25 +51,15 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
|
||||||
return
|
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
|
// can be null or empty
|
||||||
val allEntryIds = call.argument<List<Int>>("entryIds")
|
val allEntryIds = call.argument<List<Int>>("entryIds")
|
||||||
|
|
||||||
// work `Data` cannot occupy more than 10240 bytes when serialized
|
// work `Data` cannot occupy more than 10240 bytes when serialized
|
||||||
// so we save the possibly long list of entry IDs to shared preferences
|
// so we save the possibly long list of entry IDs to shared preferences
|
||||||
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
preferences.edit {
|
with(preferences.edit()) {
|
||||||
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
|
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
|
||||||
|
apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
val workData = workDataOf(
|
val workData = workDataOf(
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.*
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
@ -19,27 +15,22 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.MainActivity
|
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_EXPLORER_PATH
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY
|
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_FILTERS_STRING
|
||||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE
|
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.EXTRA_STRING_ARRAY_SEPARATOR
|
||||||
import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME
|
|
||||||
import deckers.thibault.aves.R
|
import deckers.thibault.aves.R
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.anyCauseIs
|
|
||||||
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
import deckers.thibault.aves.utils.getApplicationInfoCompat
|
||||||
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
|
import deckers.thibault.aves.utils.queryIntentActivitiesCompat
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -51,8 +42,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.*
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
@ -153,7 +143,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
val size = (sizeDip * density).roundToInt()
|
val size = (sizeDip * density).roundToInt()
|
||||||
var bytes: ByteArray? = null
|
var data: ByteArray? = null
|
||||||
try {
|
try {
|
||||||
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
|
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
|
||||||
if (iconResourceId != Resources.ID_NULL) {
|
if (iconResourceId != Resources.ID_NULL) {
|
||||||
|
@ -174,9 +164,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
// do not recycle bitmaps fetched from `ContentResolver` as their lifecycle is unknown
|
data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
|
||||||
val recycle = false
|
|
||||||
bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
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)
|
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (bytes != null) {
|
if (data != null) {
|
||||||
result.success(bytes)
|
result.success(data)
|
||||||
} else {
|
} else {
|
||||||
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
|
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
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")
|
val label = call.argument<String>("label")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("copyToClipboard-args", "missing arguments", null)
|
result.error("copyToClipboard-args", "missing arguments", null)
|
||||||
|
@ -221,7 +209,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
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 mimeType = call.argument<String>("mimeType")
|
||||||
val forceChooser = call.argument<Boolean>("forceChooser")
|
val forceChooser = call.argument<Boolean>("forceChooser")
|
||||||
if (uri == null || forceChooser == null) {
|
if (uri == null || forceChooser == null) {
|
||||||
|
@ -238,7 +226,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
if (geoUri == null) {
|
||||||
result.error("openMap-args", "missing arguments", null)
|
result.error("openMap-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -252,7 +240,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
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 mimeType = call.argument<String>("mimeType")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("setAs-args", "missing arguments", null)
|
result.error("setAs-args", "missing arguments", null)
|
||||||
|
@ -275,7 +263,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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()
|
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
||||||
|
|
||||||
// simplify share intent for a single item, as some apps can handle one item but not more
|
// simplify share intent for a single item, as some apps can handle one item but not more
|
||||||
|
@ -310,7 +298,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
val started = safeStartActivityChooser(title, intent)
|
val started = safeStartActivityChooser(title, intent)
|
||||||
result.success(started)
|
result.success(started)
|
||||||
} catch (e: Exception) {
|
} 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)
|
result.error("share-large", "transaction too large with ${uriList.size} URIs", e)
|
||||||
} else {
|
} else {
|
||||||
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
|
result.error("share-exception", "failed to share ${uriList.size} URIs", e)
|
||||||
|
@ -361,17 +349,12 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// shortcuts
|
// shortcuts
|
||||||
|
|
||||||
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
||||||
// common arguments
|
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||||
val route = call.argument<String>("route")
|
|
||||||
// route dependent arguments
|
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
val explorerPath = call.argument<String>("path")
|
val explorerPath = call.argument<String>("explorerPath")
|
||||||
val viewUri = call.argument<String>("viewUri")?.toUri()
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val geoUri = call.argument<String>("geoUri")?.toUri()
|
if (label == null) {
|
||||||
|
|
||||||
if (label == null || route == null) {
|
|
||||||
result.error("pin-args", "missing arguments", null)
|
result.error("pin-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -395,60 +378,24 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// so that foreground is rendered at the intended scale
|
// so that foreground is rendered at the intended scale
|
||||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
|
||||||
val resId = when (route) {
|
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent: Intent = when (route) {
|
val intent = when {
|
||||||
COLLECTION_PAGE_ROUTE_NAME -> {
|
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
if (filters == null) {
|
.putExtra(EXTRA_KEY_PAGE, "/collection")
|
||||||
result.error("pin-filters", "collection shortcut requires filters", null)
|
.putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||||
return
|
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||||
}
|
// so we use a joined `String` as fallback
|
||||||
Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
.putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||||
.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 -> {
|
explorerPath != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
if (viewUri == null) {
|
.putExtra(EXTRA_KEY_PAGE, "/explorer")
|
||||||
result.error("pin-viewUri", "viewer shortcut requires URI", null)
|
.putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
||||||
else -> {
|
else -> {
|
||||||
result.error("pin-route", "unsupported shortcut route=$route", null)
|
result.error("pin-intent", "failed to build intent", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.CrossProfileApps
|
|
||||||
import android.os.Build
|
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
|
||||||
|
|
||||||
class AppProfileHandler(private val activity: Activity) : MethodCallHandler {
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
when (call.method) {
|
|
||||||
"canInteractAcrossProfiles" -> safe(call, result, ::canInteractAcrossProfiles)
|
|
||||||
"canRequestInteractAcrossProfiles" -> safe(call, result, ::canRequestInteractAcrossProfiles)
|
|
||||||
"requestInteractAcrossProfiles" -> safe(call, result, ::requestInteractAcrossProfiles)
|
|
||||||
"switchProfile" -> safe(call, result, ::switchProfile)
|
|
||||||
"getProfileSwitchingLabel" -> safe(call, result, ::getProfileSwitchingLabel)
|
|
||||||
"getTargetUserProfiles" -> safe(call, result, ::getTargetUserProfiles)
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
result.success(crossProfileApps.canInteractAcrossProfiles())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canRequestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
result.success(crossProfileApps.canRequestInteractAcrossProfiles())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestInteractAcrossProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
val intent = crossProfileApps.createRequestInteractAcrossProfilesIntent()
|
|
||||||
val started = activity.startActivity(intent)
|
|
||||||
|
|
||||||
result.success(started)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun switchProfile(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
val userHandles = crossProfileApps.targetUserProfiles
|
|
||||||
crossProfileApps.startMainActivity(activity.componentName, userHandles.first())
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProfileSwitchingLabel(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
||||||
result.success(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
val userHandles = crossProfileApps.targetUserProfiles
|
|
||||||
val label = if (userHandles.isEmpty()) "" else crossProfileApps.getProfileSwitchingLabel(userHandles.first())
|
|
||||||
|
|
||||||
result.success(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTargetUserProfiles(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
||||||
result.success(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val crossProfileApps = activity.getSystemService(Context.CROSS_PROFILE_APPS_SERVICE) as CrossProfileApps
|
|
||||||
val userProfiles = crossProfileApps.targetUserProfiles.map { it.toString() }.toList()
|
|
||||||
result.success(userProfiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHANNEL = "deckers.thibault/aves/app_profile"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
|
@ -44,7 +44,6 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import org.mp4parser.IsoFile
|
import org.mp4parser.IsoFile
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -128,7 +127,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
if (uri == null) {
|
||||||
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -157,7 +156,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -213,7 +212,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
||||||
|
@ -240,7 +239,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
if (uri == null) {
|
||||||
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -265,7 +264,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
||||||
|
@ -309,14 +308,14 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMp4ParserDump-args", "missing arguments", null)
|
result.error("getMp4ParserDump-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
if (mimeType == MimeTypes.MP4 || MimeTypes.isHeic(mimeType)) {
|
if (mimeType == MimeTypes.MP4) {
|
||||||
try {
|
try {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// 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")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -339,7 +338,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPixyMetadata-args", "missing arguments", null)
|
result.error("getPixyMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -360,7 +359,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
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) {
|
if (uri == null) {
|
||||||
result.error("getTiffStructure-args", "missing arguments", null)
|
result.error("getTiffStructure-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.location.Geocoder
|
import android.location.Geocoder
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.LocaleList
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -26,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
@ -34,10 +30,9 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
||||||
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
|
"getCapabilities" -> defaultScope.launch { safe(call, result, ::getCapabilities) }
|
||||||
|
"getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis)
|
||||||
"getLocales" -> safe(call, result, ::getLocales)
|
"getLocales" -> safe(call, result, ::getLocales)
|
||||||
"setLocaleConfig" -> safe(call, result, ::setLocaleConfig)
|
|
||||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||||
"isLocked" -> safe(call, result, ::isLocked)
|
|
||||||
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
||||||
"requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission)
|
"requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission)
|
||||||
"getAvailableHeapSize" -> safe(call, result, ::getAvailableHeapSize)
|
"getAvailableHeapSize" -> safe(call, result, ::getAvailableHeapSize)
|
||||||
|
@ -54,24 +49,23 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
val sdkInt = Build.VERSION.SDK_INT
|
val sdkInt = Build.VERSION.SDK_INT
|
||||||
result.success(
|
result.success(
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
|
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||||
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
||||||
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
|
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
|
||||||
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
|
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
|
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
|
||||||
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
|
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
|
||||||
|
"canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||||
"hasGeocoder" to Geocoder.isPresent(),
|
"hasGeocoder" to Geocoder.isPresent(),
|
||||||
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
||||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
||||||
"supportPictureInPicture" to supportPictureInPicture(),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun supportPictureInPicture(): Boolean {
|
private fun getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
|
result.success(TimeZone.getDefault().rawOffset)
|
||||||
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -95,22 +89,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(locales)
|
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) {
|
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
||||||
|
@ -122,12 +100,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(Build.VERSION.SDK_INT)
|
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) {
|
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 = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).resolveActivity(context.packageManager) != null
|
||||||
result.success(enabled)
|
result.success(enabled)
|
||||||
|
@ -139,7 +111,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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)
|
context.startActivity(intent)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPUtils
|
import com.adobe.internal.xmp.XMPUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.MultiPage
|
import deckers.thibault.aves.metadata.MultiPage
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
|
@ -17,11 +18,11 @@ import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
|
||||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||||
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -45,7 +46,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getExifThumbnails" -> ioScope.launch { safe(call, result, ::getExifThumbnails) }
|
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
|
||||||
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
|
||||||
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
|
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
|
||||||
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
|
||||||
|
@ -56,9 +57,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 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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifThumbnails-args", "missing arguments", null)
|
result.error("getExifThumbnails-args", "missing arguments", null)
|
||||||
|
@ -73,9 +74,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
exif.thumbnailBitmap?.let { bitmap ->
|
exif.thumbnailBitmap?.let { bitmap ->
|
||||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||||
// do not recycle bitmaps fetched from `ExifInterface` as their lifecycle is unknown
|
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
||||||
val recycle = false
|
|
||||||
BitmapUtils.getRawBytes(it, recycle = recycle)?.let { bytes -> thumbnails.add(bytes) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,7 +88,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataUri = call.argument<String>("dataUri")
|
val dataUri = call.argument<String>("dataUri")
|
||||||
|
@ -144,7 +143,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val id = call.argument<Int>("id")
|
val id = call.argument<Int>("id")
|
||||||
|
@ -178,7 +177,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -186,7 +185,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiPage.getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
val imageSizeBytes = sizeBytes - videoSizeBytes
|
val imageSizeBytes = sizeBytes - videoSizeBytes
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
||||||
|
@ -199,7 +198,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -207,10 +206,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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 ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
input.skip(videoOffset)
|
input.skip(videoStartOffset)
|
||||||
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSize)
|
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSizeBytes)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -219,7 +219,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
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")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
||||||
|
@ -251,7 +251,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataProp = call.argument<List<Any>>("propPath")
|
val dataProp = call.argument<List<Any>>("propPath")
|
||||||
|
@ -311,7 +311,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
embeddedByteStream: InputStream,
|
embeddedByteStream: InputStream,
|
||||||
embeddedByteLength: Long,
|
embeddedByteLength: Long,
|
||||||
) {
|
) {
|
||||||
val extension = extensionFor(mimeType, defaultExtension = null)
|
val extension = extensionFor(mimeType)
|
||||||
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
||||||
transferFrom(embeddedByteStream, embeddedByteLength)
|
transferFrom(embeddedByteStream, embeddedByteLength)
|
||||||
}
|
}
|
||||||
|
@ -319,7 +319,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val authority = "${context.applicationContext.packageName}.file_provider"
|
val authority = "${context.applicationContext.packageName}.file_provider"
|
||||||
val uri = if (displayName != null) {
|
val uri = if (displayName != null) {
|
||||||
// add extension to ease type identification when sharing this content
|
// 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
|
displayName
|
||||||
} else {
|
} else {
|
||||||
"$displayName$extension"
|
"$displayName$extension"
|
||||||
|
@ -329,8 +329,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
FileProvider.getUriForFile(context, authority, targetFile)
|
FileProvider.getUriForFile(context, authority, targetFile)
|
||||||
}
|
}
|
||||||
val resultFields: FieldMap = hashMapOf(
|
val resultFields: FieldMap = hashMapOf(
|
||||||
EntryFields.URI to uri.toString(),
|
"uri" to uri.toString(),
|
||||||
EntryFields.MIME_TYPE to mimeType,
|
"mimeType" to mimeType,
|
||||||
)
|
)
|
||||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||||
val provider = getProvider(context, uri)
|
val provider = getProvider(context, uri)
|
||||||
|
|
|
@ -31,7 +31,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
||||||
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val latitude = call.argument<Number>("latitude")?.toDouble()
|
val latitude = call.argument<Number>("latitude")?.toDouble()
|
||||||
val longitude = call.argument<Number>("longitude")?.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
|
val maxResults = call.argument<Int>("maxResults") ?: 1
|
||||||
if (latitude == null || longitude == null) {
|
if (latitude == null || longitude == null) {
|
||||||
result.error("getAddress-args", "missing arguments", null)
|
result.error("getAddress-args", "missing arguments", null)
|
||||||
|
@ -43,8 +43,11 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
geocoder = geocoder ?: if (localeLanguageTag != null) {
|
geocoder = geocoder ?: if (localeString != null) {
|
||||||
Geocoder(context, Locale.forLanguageTag(localeLanguageTag))
|
val split = localeString.split("_")
|
||||||
|
val language = split[0]
|
||||||
|
val country = if (split.size > 1) split[1] else ""
|
||||||
|
Geocoder(context, Locale(language, country))
|
||||||
} else {
|
} else {
|
||||||
Geocoder(context)
|
Geocoder(context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
|
||||||
import deckers.thibault.aves.SearchSuggestionsProvider
|
import deckers.thibault.aves.SearchSuggestionsProvider
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -30,8 +29,9 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
|
||||||
preferences.edit {
|
with(preferences.edit()) {
|
||||||
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
|
||||||
|
apply()
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
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.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
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) {
|
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 desiredName = call.argument<String>("desiredName")
|
||||||
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||||
val bytes = call.argument<ByteArray>("bytes")
|
val bytes = call.argument<ByteArray>("bytes")
|
||||||
|
|
|
@ -2,13 +2,12 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import androidx.core.net.toUri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
@ -17,7 +16,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
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) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getThumbnail" -> ioScope.launch { safe(call, result, ::getThumbnail) }
|
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
|
||||||
"getRegion" -> ioScope.launch { safe(call, result, ::getRegion) }
|
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>(EntryFields.URI)
|
val uri = call.argument<String>("uri")
|
||||||
val mimeType = call.argument<String>(EntryFields.MIME_TYPE)
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong()
|
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
|
||||||
val rotationDegrees = call.argument<Int>(EntryFields.ROTATION_DEGREES)
|
val rotationDegrees = call.argument<Int>("rotationDegrees")
|
||||||
val isFlipped = call.argument<Boolean>(EntryFields.IS_FLIPPED)
|
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||||
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
||||||
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
||||||
val pageId = call.argument<Int>("pageId")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
||||||
val quality = call.argument<Int>("quality")
|
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)
|
result.error("getThumbnail-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -56,7 +54,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
context = context,
|
context = context,
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
|
dateModifiedSecs = dateModifiedSecs,
|
||||||
rotationDegrees = rotationDegrees,
|
rotationDegrees = rotationDegrees,
|
||||||
isFlipped = isFlipped,
|
isFlipped = isFlipped,
|
||||||
width = (widthDip * density).roundToInt(),
|
width = (widthDip * density).roundToInt(),
|
||||||
|
@ -68,8 +66,8 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
).fetch()
|
).fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.toUri()
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val pageId = call.argument<Int>("pageId")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
@ -97,7 +95,6 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
imageHeight = imageHeight,
|
imageHeight = imageHeight,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
|
|
||||||
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
|
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
page = pageId ?: 0,
|
page = pageId ?: 0,
|
||||||
|
@ -105,7 +102,6 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> regionFetcher.fetch(
|
else -> regionFetcher.fetch(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Handler
|
import android.net.Uri
|
||||||
import android.os.Looper
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -23,15 +21,14 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
|
||||||
"clearImageDiskCache" -> ioScope.launch { safe(call, result, ::clearImageDiskCache) }
|
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||||
"clearImageMemoryCache" -> ioScope.launch { safe(call, result, ::clearImageMemoryCache) }
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||||
val uri = call.argument<String>("uri")?.toUri()
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getEntry-args", "missing arguments", null)
|
result.error("getEntry-args", "missing arguments", null)
|
||||||
|
@ -50,18 +47,11 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
Glide.get(context).clearDiskCache()
|
||||||
result.success(null)
|
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 {
|
companion object {
|
||||||
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
|
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.*
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.media.session.MediaButtonReceiver
|
import androidx.media.session.MediaButtonReceiver
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
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) {
|
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 title = call.argument<String>("title") ?: uri?.toString()
|
||||||
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
||||||
val stateString = call.argument<String>("state")
|
val stateString = call.argument<String>("state")
|
||||||
|
|
|
@ -59,14 +59,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getGeneration(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getGeneration(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
val generation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
val generation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
try {
|
MediaStore.getGeneration(context, MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||||
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 {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
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.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
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 path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
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 path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
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 path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
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 path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
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 path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.adobe.internal.xmp.XMPMetaFactory
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
|
@ -20,10 +20,10 @@ import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.exif.ExifSubIFDDirectory
|
import com.drew.metadata.exif.ExifSubIFDDirectory
|
||||||
import com.drew.metadata.exif.GpsDirectory
|
import com.drew.metadata.exif.GpsDirectory
|
||||||
|
import com.drew.metadata.exif.makernotes.AppleMakernoteDirectory
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory
|
import com.drew.metadata.gif.GifAnimationDirectory
|
||||||
import com.drew.metadata.iptc.IptcDirectory
|
import com.drew.metadata.iptc.IptcDirectory
|
||||||
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
|
|
||||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.png.PngDirectory
|
import com.drew.metadata.png.PngDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
|
@ -102,15 +102,12 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
|
||||||
import org.mp4parser.tools.Path
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -135,7 +132,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getAllMetadata-args", "missing arguments", null)
|
result.error("getAllMetadata-args", "missing arguments", null)
|
||||||
|
@ -167,11 +164,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
// remove this stat as it is not actual XMP data
|
// remove this stat as it is not actual XMP data
|
||||||
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
dirMap.remove(XmpDirectory().getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||||
if (dirMap.isNotEmpty()) {
|
// add schema prefixes for namespace resolution
|
||||||
// add schema prefixes for namespace resolution
|
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
||||||
val prefixes = XMPMetaFactory.getSchemaRegistry().prefixes
|
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
||||||
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val mp4UuidDirCount = HashMap<String, Int>()
|
val mp4UuidDirCount = HashMap<String, Int>()
|
||||||
|
@ -452,8 +447,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
// `metadata-extractor` do not extract custom tags in user data box
|
// `metadata-extractor` do not extract custom tags in user data box
|
||||||
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { box ->
|
val userDataDir = Mp4ParserHelper.getUserData(context, mimeType, uri)
|
||||||
metadataMap[Metadata.DIR_MP4_USER_DATA] = Mp4ParserHelper.extractBoxFields(box)
|
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
|
// this is used as fallback when the video metadata cannot be found on the Dart side
|
||||||
|
@ -472,12 +468,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
||||||
// about embedded images as they do not list them as separate tracks
|
// about embedded images as they do not list them as separate tracks
|
||||||
// and only identify at most one
|
// 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()) {
|
if (metadataMap.isNotEmpty()) {
|
||||||
|
@ -525,7 +515,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// - XMP / MicrosoftPhoto:Rating
|
// - XMP / MicrosoftPhoto:Rating
|
||||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 path = call.argument<String>("path")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
|
@ -535,33 +525,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val metadataMap = HashMap<String, Any>()
|
val metadataMap = HashMap<String, Any>()
|
||||||
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
||||||
|
|
||||||
if (isVideo(mimeType) || isHeic(mimeType)) {
|
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
|
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
|
// report success even when empty
|
||||||
|
@ -695,7 +660,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
|
if (metadata.getDirectoriesOfType(MpEntryDirectory::class.java).count { !it.entry.isThumbnail } > 1) {
|
||||||
flags = flags or MASK_IS_MULTIPAGE
|
flags = flags or MASK_IS_MULTIPAGE
|
||||||
|
|
||||||
if (hasAppleHdrGainMap(uri, sizeBytes)) {
|
if (hasAppleHdrGainMap(uri, sizeBytes, metadata)) {
|
||||||
flags = flags or MASK_IS_HDR
|
flags = flags or MASK_IS_HDR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -719,22 +684,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) {
|
when (mimeType) {
|
||||||
MimeTypes.PNG -> {
|
MimeTypes.PNG -> {
|
||||||
// date fallback to PNG time chunk
|
// date fallback to PNG time chunk
|
||||||
|
@ -840,9 +789,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
metadataMap[KEY_FLAGS] = flags
|
metadataMap[KEY_FLAGS] = flags
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?): Boolean {
|
private fun hasAppleHdrGainMap(uri: Uri, sizeBytes: Long?, primaryMetadata: com.drew.metadata.Metadata): Boolean {
|
||||||
|
if (!primaryMetadata.containsDirectoryOfType(AppleMakernoteDirectory::class.java)) return false
|
||||||
|
|
||||||
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
|
val mpEntries = MultiPage.getJpegMpfEntries(context, uri, sizeBytes) ?: return false
|
||||||
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEachIndexed { mpIndex, mpEntry ->
|
mpEntries.filter { it.type == MpEntry.TYPE_UNDEFINED }.forEach { mpEntry ->
|
||||||
var dataOffset = mpEntry.dataOffset
|
var dataOffset = mpEntry.dataOffset
|
||||||
if (dataOffset > 0) {
|
if (dataOffset > 0) {
|
||||||
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
val baseOffset = MultiPage.getJpegMpfBaseOffset(context, uri, sizeBytes)
|
||||||
|
@ -852,13 +803,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
input.skip(dataOffset)
|
input.skip(dataOffset)
|
||||||
try {
|
val pageMetadata = Helper.safeRead(input, sizeBytes)
|
||||||
val pageMetadata = Helper.safeRead(input, sizeBytes)
|
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
|
||||||
if (pageMetadata.getDirectoriesOfType(XmpDirectory::class.java).any { it.xmpMeta.hasHdrGainMap() }) {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for uri=$uri mpIndex=$mpIndex mpEntry=$mpEntry", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -879,7 +826,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
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)
|
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||||
if (locationString != null) {
|
if (locationString != null) {
|
||||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||||
|
@ -919,7 +866,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
@ -1007,7 +954,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns description from these fields (by precedence):
|
// return description from these fields (by precedence):
|
||||||
// - XMP / dc:description
|
// - XMP / dc:description
|
||||||
// - IPTC / caption-abstract
|
// - IPTC / caption-abstract
|
||||||
// - Exif / UserComment
|
// - Exif / UserComment
|
||||||
|
@ -1050,7 +997,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
||||||
|
@ -1091,7 +1038,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
||||||
|
@ -1118,7 +1065,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPanoramaInfo-args", "missing arguments", null)
|
result.error("getPanoramaInfo-args", "missing arguments", null)
|
||||||
|
@ -1170,7 +1117,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getIptc-args", "missing arguments", null)
|
result.error("getIptc-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -1192,11 +1139,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns XMP components
|
// return XMP components
|
||||||
// returns an empty list if there is no XMP
|
// return an empty list if there is no XMP
|
||||||
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getXmp-args", "missing arguments", null)
|
result.error("getXmp-args", "missing arguments", null)
|
||||||
|
@ -1268,7 +1215,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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")
|
val prop = call.argument<String>("prop")
|
||||||
if (mimeType == null || uri == null || prop == null) {
|
if (mimeType == null || uri == null || prop == null) {
|
||||||
result.error("getContentPropValue-args", "missing arguments", null)
|
result.error("getContentPropValue-args", "missing arguments", null)
|
||||||
|
@ -1285,7 +1232,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val field = call.argument<String>("field")
|
val field = call.argument<String>("field")
|
||||||
if (mimeType == null || uri == null || field == null) {
|
if (mimeType == null || uri == null || field == null) {
|
||||||
|
@ -1354,7 +1301,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
|
|
@ -2,7 +2,6 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
@ -46,7 +45,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
val preferences = getStore()
|
val preferences = getStore()
|
||||||
preferences.edit {
|
with(preferences.edit()) {
|
||||||
when (value) {
|
when (value) {
|
||||||
is Boolean -> putBoolean(key, value)
|
is Boolean -> putBoolean(key, value)
|
||||||
is Float -> putFloat(key, value)
|
is Float -> putFloat(key, value)
|
||||||
|
@ -59,6 +58,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
apply()
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
|
"getDataUsage" -> ioScope.launch { safe(call, result, ::getDataUsage) }
|
||||||
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
||||||
"getCacheDirectory" -> ioScope.launch { safe(call, result, ::getCacheDirectory) }
|
|
||||||
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
|
"getUntrackedTrashPaths" -> ioScope.launch { safe(call, result, ::getUntrackedTrashPaths) }
|
||||||
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
|
"getUntrackedVaultPaths" -> ioScope.launch { safe(call, result, ::getUntrackedVaultPaths) }
|
||||||
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
||||||
|
@ -123,18 +122,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(volumes)
|
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) {
|
private fun getUntrackedTrashPaths(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val knownPaths = call.argument<List<String>>("knownPaths")
|
val knownPaths = call.argument<List<String>>("knownPaths")
|
||||||
if (knownPaths == null) {
|
if (knownPaths == null) {
|
||||||
|
|
|
@ -4,24 +4,21 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.ColorSpace
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MathUtils
|
import deckers.thibault.aves.utils.MathUtils
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@ -31,10 +28,16 @@ import kotlin.math.roundToInt
|
||||||
class RegionFetcher internal constructor(
|
class RegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
// returns decoded bytes in ARGB_8888, with trailer bytes:
|
private var lastDecoderRef: LastDecoderRef? = null
|
||||||
// - width (int32)
|
|
||||||
// - height (int32)
|
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
||||||
fun fetch(
|
|
||||||
|
private val multiTrackGlideOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
|
suspend fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
pageId: Int?,
|
pageId: Int?,
|
||||||
|
@ -42,31 +45,41 @@ class RegionFetcher internal constructor(
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
imageWidth: Int,
|
imageWidth: Int,
|
||||||
imageHeight: Int,
|
imageHeight: Int,
|
||||||
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
|
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
// use JPEG export for requested page
|
val id = Pair(uri, pageId)
|
||||||
fetch(
|
fetch(
|
||||||
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) },
|
||||||
mimeType = MimeTypes.JPEG,
|
mimeType = MimeTypes.JPEG,
|
||||||
pageId = null,
|
pageId = null,
|
||||||
sampleSize = sampleSize,
|
sampleSize = sampleSize,
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
imageWidth = imageWidth,
|
imageWidth = imageWidth,
|
||||||
imageHeight = imageHeight,
|
imageHeight = imageHeight,
|
||||||
requestKey = requestKey,
|
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentDecoderRef = lastDecoderRef
|
||||||
|
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
||||||
|
currentDecoderRef = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val decoder = getOrCreateDecoder(context, uri, requestKey)
|
if (currentDecoderRef == null) {
|
||||||
if (decoder == null) {
|
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
BitmapRegionDecoderCompat.newInstance(input)
|
||||||
return
|
}
|
||||||
|
if (newDecoder == null) {
|
||||||
|
result.error("fetch-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
|
// with raw images, the known image size may not match the decoded image size
|
||||||
// so we scale the requested region accordingly
|
// so we scale the requested region accordingly
|
||||||
|
@ -88,71 +101,34 @@ class RegionFetcher internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val options = BitmapFactory.Options().apply {
|
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
|
||||||
inSampleSize = effectiveSampleSize
|
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * effectiveRect.width() * effectiveRect.height() / 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)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
// decoding a region that large would yield an OOM when creating the bitmap
|
// 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)
|
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var bitmap = decoder.decodeRegion(effectiveRect, options)
|
val options = BitmapFactory.Options().apply {
|
||||||
if (bitmap == null) {
|
inSampleSize = effectiveSampleSize
|
||||||
// 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 bitmap = decoder.decodeRegion(effectiveRect, options)
|
||||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
if (bitmap != null) {
|
||||||
if (bytes != null) {
|
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
|
||||||
result.success(bytes)
|
|
||||||
} else {
|
} else {
|
||||||
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
|
private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri {
|
||||||
Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId")
|
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
.apply(multiTrackGlideOptions)
|
||||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
.load(MultiPageImage(context, sourceUri, mimeType, pageId))
|
||||||
.submit()
|
.submit()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = target.get()
|
val bitmap = target.get()
|
||||||
val tempFile = StorageUtils.createTempFile(context).apply {
|
val tempFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
@ -166,40 +142,8 @@ class RegionFetcher internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DecoderRef(
|
private data class LastDecoderRef(
|
||||||
val requestKey: Pair<Uri, Int?>,
|
val uri: Uri,
|
||||||
val decoder: BitmapRegionDecoder,
|
val decoder: BitmapRegionDecoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
|
|
||||||
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
|
||||||
private const val DECODER_POOL_SIZE = 3
|
|
||||||
private val decoderPool = ArrayList<DecoderRef>()
|
|
||||||
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
|
|
||||||
|
|
||||||
private val poolLock = ReentrantLock()
|
|
||||||
|
|
||||||
private fun getOrCreateDecoder(context: Context, uri: Uri, requestKey: Pair<Uri, Int?>): BitmapRegionDecoder? {
|
|
||||||
poolLock.withLock {
|
|
||||||
var decoderRef = decoderPool.firstOrNull { it.requestKey == requestKey }
|
|
||||||
if (decoderRef == null) {
|
|
||||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
|
||||||
BitmapRegionDecoderCompat.newInstance(input)
|
|
||||||
}
|
|
||||||
if (newDecoder == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
decoderRef = DecoderRef(requestKey, newDecoder)
|
|
||||||
} else {
|
|
||||||
decoderPool.remove(decoderRef)
|
|
||||||
}
|
|
||||||
decoderPool.add(0, decoderRef)
|
|
||||||
while (decoderPool.size > DECODER_POOL_SIZE) {
|
|
||||||
decoderPool.removeAt(decoderPool.size - 1)
|
|
||||||
}
|
|
||||||
return decoderRef.decoder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,25 @@ import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import com.caverock.androidsvg.PreserveAspectRatio
|
import com.caverock.androidsvg.PreserveAspectRatio
|
||||||
import com.caverock.androidsvg.RenderOptions
|
import com.caverock.androidsvg.RenderOptions
|
||||||
import com.caverock.androidsvg.SVG
|
import com.caverock.androidsvg.SVG
|
||||||
import com.caverock.androidsvg.SVGParseException
|
import com.caverock.androidsvg.SVGParseException
|
||||||
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
|
||||||
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
class SvgRegionFetcher internal constructor(
|
class SvgRegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
fun fetch(
|
private var lastSvgRef: LastSvgRef? = null
|
||||||
|
|
||||||
|
suspend fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
scale: Int,
|
scale: Int,
|
||||||
|
@ -39,12 +39,32 @@ class SvgRegionFetcher internal constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentSvgRef = lastSvgRef
|
||||||
|
if (currentSvgRef != null && currentSvgRef.uri != uri) {
|
||||||
|
currentSvgRef = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val svg = getOrCreateDecoder(context, uri)
|
if (currentSvgRef == null) {
|
||||||
if (svg == null) {
|
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
try {
|
||||||
return
|
SVG.getFromInputStream(SVGParserBufferedInputStream(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
|
// we scale the requested region accordingly to the viewbox size
|
||||||
val viewBox = svg.documentViewBox
|
val viewBox = svg.documentViewBox
|
||||||
|
@ -71,65 +91,32 @@ class SvgRegionFetcher internal constructor(
|
||||||
|
|
||||||
val targetBitmapWidth = regionRect.width()
|
val targetBitmapWidth = regionRect.width()
|
||||||
val targetBitmapHeight = regionRect.height()
|
val targetBitmapHeight = regionRect.height()
|
||||||
val canvasWidth = targetBitmapWidth + bleedX * 2
|
|
||||||
val canvasHeight = targetBitmapHeight + bleedY * 2
|
|
||||||
|
|
||||||
val config = PREFERRED_CONFIG
|
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
|
||||||
val pixelCount = canvasWidth * canvasHeight
|
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * targetBitmapWidth * targetBitmapHeight
|
||||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), config)
|
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
// decoding a region that large would yield an OOM when creating the bitmap
|
// 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)
|
result.error("fetch-read-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
|
||||||
return
|
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)
|
val canvas = Canvas(bitmap)
|
||||||
svg.renderToCanvas(canvas, renderOptions)
|
svg.renderToCanvas(canvas, renderOptions)
|
||||||
|
|
||||||
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
|
||||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||||
result.success(bytes)
|
|
||||||
} catch (e: SVGParseException) {
|
|
||||||
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
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 uri: Uri,
|
||||||
val decoder: SVG,
|
val svg: SVG,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
|
|
||||||
private const val DECODER_POOL_SIZE = 3
|
|
||||||
private val decoderPool = ArrayList<DecoderRef>()
|
|
||||||
|
|
||||||
private val poolLock = ReentrantLock()
|
|
||||||
|
|
||||||
private fun getOrCreateDecoder(context: Context, uri: Uri): SVG? {
|
|
||||||
poolLock.withLock {
|
|
||||||
var decoderRef = decoderPool.firstOrNull { it.uri == uri }
|
|
||||||
if (decoderRef == null) {
|
|
||||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
|
||||||
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
|
|
||||||
}
|
|
||||||
if (newDecoder == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
newDecoder.normalizeSize()
|
|
||||||
decoderRef = DecoderRef(uri, newDecoder)
|
|
||||||
} else {
|
|
||||||
decoderPool.remove(decoderRef)
|
|
||||||
}
|
|
||||||
decoderPool.add(0, decoderRef)
|
|
||||||
while (decoderPool.size > DECODER_POOL_SIZE) {
|
|
||||||
decoderPool.removeAt(decoderPool.size - 1)
|
|
||||||
}
|
|
||||||
return decoderRef.decoder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,21 +5,19 @@ import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.graphics.scale
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
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.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
|
||||||
import deckers.thibault.aves.utils.MimeTypes.SVG
|
import deckers.thibault.aves.utils.MimeTypes.SVG
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
@ -28,14 +26,12 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
class ThumbnailFetcher internal constructor(
|
class ThumbnailFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
uri: String,
|
uri: String,
|
||||||
private val mimeType: String,
|
private val mimeType: String,
|
||||||
private val dateModifiedMillis: Long,
|
private val dateModifiedSecs: Long,
|
||||||
private val rotationDegrees: Int,
|
private val rotationDegrees: Int,
|
||||||
private val isFlipped: Boolean,
|
private val isFlipped: Boolean,
|
||||||
width: Int?,
|
width: Int?,
|
||||||
|
@ -45,7 +41,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val quality: Int,
|
private val quality: Int,
|
||||||
private val result: MethodChannel.Result,
|
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 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 height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val svgFetch = mimeType == SVG
|
private val svgFetch = mimeType == SVG
|
||||||
|
@ -53,7 +49,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
|
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
|
||||||
private val customFetch = svgFetch || tiffFetch || multiPageFetch
|
private val customFetch = svgFetch || tiffFetch || multiPageFetch
|
||||||
|
|
||||||
fun fetch() {
|
suspend fun fetch() {
|
||||||
var bitmap: Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
var exception: Exception? = null
|
var exception: Exception? = null
|
||||||
|
|
||||||
|
@ -83,33 +79,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
if (bitmap.width > width && bitmap.height > height) {
|
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
var errorDetails: String? = exception?.message
|
var errorDetails: String? = exception?.message
|
||||||
if (errorDetails?.isNotEmpty() == true) {
|
if (errorDetails?.isNotEmpty() == true) {
|
||||||
|
@ -150,17 +120,29 @@ class ThumbnailFetcher internal constructor(
|
||||||
// add signature to ignore cache for images which got modified but kept the same URI
|
// add signature to ignore cache for images which got modified but kept the same URI
|
||||||
var options = RequestOptions()
|
var options = RequestOptions()
|
||||||
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
.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)
|
.override(width, height)
|
||||||
if (isVideo(mimeType)) {
|
|
||||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
|
||||||
}
|
|
||||||
|
|
||||||
val target = Glide.with(context)
|
val target = if (isVideo(mimeType)) {
|
||||||
.asBitmap()
|
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||||
.apply(options)
|
Glide.with(context)
|
||||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
.asBitmap()
|
||||||
.submit(width, height)
|
.apply(options)
|
||||||
|
.load(VideoThumbnail(context, uri))
|
||||||
|
.submit(width, height)
|
||||||
|
} else {
|
||||||
|
val model: Any = when {
|
||||||
|
svgFetch -> SvgImage(context, uri)
|
||||||
|
tiffFetch -> TiffImage(context, uri, pageId)
|
||||||
|
multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId)
|
||||||
|
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
||||||
|
}
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.apply(options)
|
||||||
|
.load(model)
|
||||||
|
.submit(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
|
@ -172,9 +154,4 @@ class ThumbnailFetcher internal constructor(
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val LOG_TAG = LogUtils.createTag<ThumbnailFetcher>()
|
|
||||||
private const val BITMAP_SIZE_DANGER_THRESHOLD = 20 * (1 shl 20) // MB
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
package deckers.thibault.aves.channel.calls.fetchers
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import org.beyka.tiffbitmapfactory.DecodeArea
|
import org.beyka.tiffbitmapfactory.DecodeArea
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
@ -12,7 +11,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
class TiffRegionFetcher internal constructor(
|
class TiffRegionFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
fun fetch(
|
suspend fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
page: Int,
|
page: Int,
|
||||||
sampleSize: Int,
|
sampleSize: Int,
|
||||||
|
@ -32,10 +31,9 @@ class TiffRegionFetcher internal constructor(
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
|
||||||
}
|
}
|
||||||
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
|
if (bitmap != null) {
|
||||||
if (bytes != null) {
|
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
||||||
result.success(bytes)
|
|
||||||
} else {
|
} else {
|
||||||
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,30 +77,19 @@ 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) {
|
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.resources.configuration.isScreenHdr)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity.getDisplayCompat()?.isHdr ?: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val wideColorGamut = call.argument<Boolean>("wideColorGamut")
|
val on = call.argument<Boolean>("on")
|
||||||
val hdr = call.argument<Boolean>("hdr")
|
if (on == null) {
|
||||||
if (wideColorGamut == null || hdr == null) {
|
result.error("setHdrColorMode-args", "missing arguments", null)
|
||||||
result.error("setColorMode-args", "missing arguments", null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
activity.window.colorMode = if (hdr) {
|
activity.window.colorMode = if (on) ActivityInfo.COLOR_MODE_HDR else ActivityInfo.COLOR_MODE_DEFAULT
|
||||||
ActivityInfo.COLOR_MODE_HDR
|
|
||||||
} else if (wideColorGamut) {
|
|
||||||
ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
|
|
||||||
} else {
|
|
||||||
ActivityInfo.COLOR_MODE_DEFAULT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,15 +29,11 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
|
||||||
result.success(HashMap<String, Any>())
|
result.success(HashMap<String, Any>())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
result.success(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(false)
|
result.success(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
|
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -18,9 +18,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
||||||
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
||||||
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
||||||
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
|
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
|
||||||
"supportsWideGamut" -> Coresult.safe(call, result, ::supportsWideGamut)
|
|
||||||
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
|
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
|
||||||
"setColorMode" -> Coresult.safe(call, result, ::setColorMode)
|
"setHdrColorMode" -> Coresult.safe(call, result, ::setHdrColorMode)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,11 +46,9 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
||||||
|
|
||||||
abstract fun getCutoutInsets(call: MethodCall, result: MethodChannel.Result)
|
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 supportsHdr(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
abstract fun setColorMode(call: MethodCall, result: MethodChannel.Result)
|
abstract fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
||||||
|
|
|
@ -7,7 +7,6 @@ import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
|
@ -49,7 +48,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
|
||||||
"createFile" -> ioScope.launch { createFile() }
|
"createFile" -> ioScope.launch { createFile() }
|
||||||
"openFile" -> ioScope.launch { openFile() }
|
"openFile" -> ioScope.launch { openFile() }
|
||||||
"copyFile" -> ioScope.launch { copyFile() }
|
|
||||||
"edit" -> edit()
|
"edit" -> edit()
|
||||||
"pickCollectionFilters" -> pickCollectionFilters()
|
"pickCollectionFilters" -> pickCollectionFilters()
|
||||||
else -> endOfStream()
|
else -> endOfStream()
|
||||||
|
@ -73,7 +71,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestMediaFileAccess() {
|
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 }
|
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
||||||
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
||||||
error("requestMediaFileAccess-args", "missing arguments", null)
|
error("requestMediaFileAccess-args", "missing arguments", null)
|
||||||
|
@ -182,49 +180,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
|
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() {
|
private fun edit() {
|
||||||
val uri = args["uri"] as String?
|
val uri = args["uri"] as String?
|
||||||
val mimeType = args["mimeType"] as String? // optional
|
val mimeType = args["mimeType"] as String? // optional
|
||||||
|
@ -235,7 +190,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_EDIT)
|
val intent = Intent(Intent.ACTION_EDIT)
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
|
.setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType)
|
||||||
|
|
||||||
if (intent.resolveActivity(activity.packageManager) == null) {
|
if (intent.resolveActivity(activity.packageManager) == null) {
|
||||||
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
||||||
|
|
|
@ -5,11 +5,15 @@ import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
|
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.applyExifOrientation
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -24,7 +28,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
|
||||||
|
@ -81,13 +84,11 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val decoded = arguments["decoded"] as Boolean
|
|
||||||
val mimeType = arguments["mimeType"] as String?
|
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 sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
||||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||||
val isFlipped = arguments["isFlipped"] as Boolean
|
val isFlipped = arguments["isFlipped"] as Boolean
|
||||||
val isAnimated = arguments["isAnimated"] as Boolean
|
|
||||||
val pageId = arguments["pageId"] as Int?
|
val pageId = arguments["pageId"] as Int?
|
||||||
|
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
|
@ -96,31 +97,19 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
|
if (isVideo(mimeType)) {
|
||||||
// to be decoded by Flutter
|
streamVideoByGlide(uri, mimeType, sizeBytes)
|
||||||
streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
|
} else if (!canDecodeWithFlutter(mimeType, pageId, rotationDegrees, isFlipped)) {
|
||||||
} else if (isVideo(mimeType)) {
|
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||||
streamVideoByGlide(
|
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
|
||||||
uri = uri,
|
|
||||||
mimeType = mimeType,
|
|
||||||
sizeBytes = sizeBytes,
|
|
||||||
decoded = decoded,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
streamImageByGlide(
|
// to be decoded by Flutter
|
||||||
uri = uri,
|
streamImageAsIs(uri, mimeType, sizeBytes)
|
||||||
pageId = pageId,
|
|
||||||
mimeType = mimeType,
|
|
||||||
sizeBytes = sizeBytes,
|
|
||||||
rotationDegrees = rotationDegrees,
|
|
||||||
isFlipped = isFlipped,
|
|
||||||
decoded = decoded,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
endOfStream()
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||||
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
if (!MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
return
|
return
|
||||||
|
@ -140,12 +129,19 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
rotationDegrees: Int,
|
rotationDegrees: Int,
|
||||||
isFlipped: Boolean,
|
isFlipped: Boolean,
|
||||||
decoded: Boolean,
|
|
||||||
) {
|
) {
|
||||||
|
val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
|
MultiPageImage(context, uri, mimeType, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(context, uri, pageId)
|
||||||
|
} else {
|
||||||
|
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
.apply(glideOptions)
|
||||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
|
.load(model)
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
|
@ -153,16 +149,9 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||||
}
|
}
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
|
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
|
||||||
val recycle = false
|
|
||||||
val bytes = if (decoded) {
|
|
||||||
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
|
||||||
} else {
|
|
||||||
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = MimeTypes.canHaveAlpha(mimeType), recycle = recycle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
streamBytes(ByteArrayInputStream(bytes))
|
success(bytes)
|
||||||
} else {
|
} else {
|
||||||
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
@ -170,31 +159,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)
|
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 {
|
} finally {
|
||||||
Glide.with(context).clear(target)
|
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)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
.apply(glideOptions)
|
||||||
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
|
.load(VideoThumbnail(context, uri))
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
|
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
|
||||||
val recycle = false
|
|
||||||
val bytes = if (decoded) {
|
|
||||||
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
|
|
||||||
} else {
|
|
||||||
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = false, recycle = recycle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MemoryUtils.canAllocate(sizeBytes)) {
|
if (MemoryUtils.canAllocate(sizeBytes)) {
|
||||||
streamBytes(ByteArrayInputStream(bytes))
|
success(bytes)
|
||||||
} else {
|
} else {
|
||||||
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
@ -236,5 +218,11 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
||||||
|
|
||||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
|
|
||||||
|
// request a fresh image with the highest quality format
|
||||||
|
private val glideOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.streams
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
|
||||||
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -141,7 +141,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
// assume same provider for all entries
|
// assume same provider for all entries
|
||||||
val firstEntry = entryMapList.first()
|
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(activity, it) }
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -31,15 +31,9 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.i(LOG_TAG, "start listening to Media Store")
|
Log.i(LOG_TAG, "start listening to Media Store")
|
||||||
try {
|
context.contentResolver.apply {
|
||||||
context.contentResolver.apply {
|
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||||
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
registerContentObserver(MediaStore.Video.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,14 +19,15 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
||||||
private lateinit var eventSink: EventSink
|
private lateinit var eventSink: EventSink
|
||||||
private lateinit var handler: Handler
|
private lateinit var handler: Handler
|
||||||
|
|
||||||
// knownEntries: map of contentId -> dateModifiedMillis
|
private var knownEntries: Map<Long?, Int?>? = null
|
||||||
private var knownEntries: Map<Long?, Long?>? = null
|
|
||||||
private var directory: String? = null
|
private var directory: String? = null
|
||||||
|
private var safe: Boolean = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (arguments is Map<*, *>) {
|
if (arguments is Map<*, *>) {
|
||||||
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap()
|
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap()
|
||||||
directory = arguments["directory"] as String?
|
directory = arguments["directory"] as String?
|
||||||
|
safe = arguments.getOrDefault("safe", false) as Boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +61,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchAll() {
|
private fun fetchAll() {
|
||||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) }
|
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory, safe) { success(it) }
|
||||||
endOfStream()
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewConfiguration
|
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
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 val contentObserver = object : ContentObserver(null) {
|
||||||
private var accelerometerRotation: Int = 0
|
private var accelerometerRotation: Int = 0
|
||||||
private var transitionAnimationScale: Float = 1f
|
private var transitionAnimationScale: Float = 1f
|
||||||
private var longPressTimeoutMillis: Int = 0
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
update()
|
update()
|
||||||
|
@ -38,7 +36,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
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
|
transitionAnimationScale = newTransitionAnimationScale
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
val newLongPressTimeout = ViewConfiguration.getLongPressTimeout()
|
|
||||||
if (longPressTimeoutMillis != newLongPressTimeout) {
|
|
||||||
longPressTimeoutMillis = newLongPressTimeout
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
}
|
}
|
||||||
|
@ -101,8 +93,5 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
||||||
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
||||||
|
|
||||||
// cf `Settings.Secure.LONG_PRESS_TIMEOUT`
|
|
||||||
const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,30 +1,14 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.text.format.Formatter
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.GlideBuilder
|
import com.bumptech.glide.GlideBuilder
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
import com.bumptech.glide.annotation.GlideModule
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
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.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||||
import com.bumptech.glide.module.AppGlideModule
|
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
|
import deckers.thibault.aves.utils.compatRemoveIf
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
|
@ -32,30 +16,6 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||||
// hide noisy warning (e.g. for images that can't be decoded)
|
// hide noisy warning (e.g. for images that can't be decoded)
|
||||||
builder.setLogLevel(Log.ERROR)
|
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) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
|
@ -65,28 +25,4 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isManifestParsingEnabled(): Boolean = false
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -69,7 +68,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
|
||||||
bitmapWidth = width
|
bitmapWidth = width
|
||||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
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)
|
val canvas = Canvas(bitmap)
|
||||||
svg.renderToCanvas(canvas)
|
svg.renderToCanvas(canvas)
|
||||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.decoder
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.graphics.scale
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -83,9 +82,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
|
|
||||||
|
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
|
||||||
} else if (customSize) {
|
} else if (customSize) {
|
||||||
|
@ -99,7 +96,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
|
||||||
dstWidth = width
|
dstWidth = width
|
||||||
dstHeight = (width / aspectRatio).toInt()
|
dstHeight = (width / aspectRatio).toInt()
|
||||||
}
|
}
|
||||||
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
|
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true))
|
||||||
} else {
|
} else {
|
||||||
callback.onDataReady(bitmap)
|
callback.onDataReady(bitmap)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -21,6 +20,7 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
import com.bumptech.glide.module.LibraryGlideModule
|
import com.bumptech.glide.module.LibraryGlideModule
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -28,54 +28,45 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.IOException
|
import java.io.InputStream
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
class VideoThumbnailGlideModule : LibraryGlideModule() {
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
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)
|
class VideoThumbnail(val context: Context, val uri: Uri)
|
||||||
|
|
||||||
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
|
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
||||||
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(model: VideoThumbnail): Boolean = true
|
override fun handles(model: VideoThumbnail): Boolean = true
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
|
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
||||||
|
|
||||||
override fun teardown() {}
|
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, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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 {
|
ioScope.launch {
|
||||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||||
if (retriever == null) {
|
if (retriever == null) {
|
||||||
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
var bitmap: Bitmap? = null
|
var bytes = retriever.embeddedPicture
|
||||||
|
if (bytes == null) {
|
||||||
retriever.embeddedPicture?.let { bytes ->
|
|
||||||
try {
|
|
||||||
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
|
|
||||||
} catch (e: IOException) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bitmap == null) {
|
|
||||||
// there is no consistent strategy across devices to match
|
// there is no consistent strategy across devices to match
|
||||||
// the thumbnails returned by the content resolver / Media Store
|
// the thumbnails returned by the content resolver / Media Store
|
||||||
// so we derive one in an arbitrary way
|
// so we derive one in an arbitrary way
|
||||||
|
@ -120,9 +111,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
// the returned frame is already rotated according to the video metadata
|
// 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 frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
val pixelCount = dstWidth * dstHeight
|
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
|
||||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
|
||||||
}
|
}
|
||||||
|
@ -132,8 +122,7 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val pixelCount = videoWidth * videoHeight
|
val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
|
||||||
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
|
|
||||||
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
|
||||||
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
|
||||||
}
|
}
|
||||||
|
@ -143,12 +132,13 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
retriever.getFrameAtTime(timeMicros, option)
|
retriever.getFrameAtTime(timeMicros, option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitmap == null) {
|
if (bytes != null) {
|
||||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
|
callback.onDataReady(ByteArrayInputStream(bytes))
|
||||||
} else {
|
} else {
|
||||||
callback.onDataReady(bitmap)
|
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onLoadFailed(e)
|
callback.onLoadFailed(e)
|
||||||
|
@ -161,14 +151,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
|
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
|
||||||
val params = MediaMetadataRetriever.BitmapParams()
|
preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
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)
|
// 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
|
// for wide-gamut and HDR content which does not require alpha blending
|
||||||
Bitmap.Config.RGBA_1010102
|
Bitmap.Config.RGBA_1010102
|
||||||
|
@ -183,7 +167,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
|
||||||
// cannot cancel
|
// cannot cancel
|
||||||
override fun 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
|
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// same for either `ARGB_8888` or `RGBA_1010102`
|
||||||
|
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Directory
|
import com.drew.metadata.Directory
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
|
@ -18,7 +19,6 @@ import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
|
|
|
@ -111,25 +111,20 @@ object MediaMetadataRetrieverHelper {
|
||||||
// format
|
// format
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
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_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||||
val bitrate = value.toLongOrNull() ?: 0
|
val bitrate = value.toLongOrNull() ?: 0
|
||||||
if (bitrate > 0) formatBitrate(bitrate) else null
|
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||||
if (framerate > 0.0) "$framerate" else null
|
if (framerate > 0.0) "$framerate" else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
||||||
val dateMillis = value.toLongOrNull() ?: 0
|
val dateMillis = value.toLongOrNull() ?: 0
|
||||||
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
||||||
|
@ -137,7 +132,6 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
||||||
|
@ -147,7 +141,6 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
||||||
|
@ -161,7 +154,6 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
||||||
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
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_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
||||||
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
||||||
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
||||||
|
@ -176,12 +168,4 @@ object MediaMetadataRetrieverHelper {
|
||||||
}?.let { save(it) }
|
}?.let { save(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getLong(key))
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -2,9 +2,8 @@ package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
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
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -15,11 +14,8 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
object Metadata {
|
object Metadata {
|
||||||
private val LOG_TAG = LogUtils.createTag<Metadata>()
|
|
||||||
|
|
||||||
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
const val IPTC_MARKER_BYTE: Byte = 0x1c
|
||||||
|
|
||||||
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
|
||||||
|
@ -139,42 +135,29 @@ object Metadata {
|
||||||
|
|
||||||
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
private fun getSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long?): Uri {
|
||||||
// formats known to yield OOM for large files
|
// formats known to yield OOM for large files
|
||||||
return when (mimeType) {
|
return if ((MimeTypes.isImage(mimeType) || mimeType == MimeTypes.MP4)) {
|
||||||
// formats known to yield OOM for large files
|
if (isDangerouslyLarge(sizeBytes)) {
|
||||||
MimeTypes.DNG,
|
// make a preview from the beginning of the file,
|
||||||
MimeTypes.DNG_ADOBE,
|
// hoping the metadata is accessible in the copied chunk
|
||||||
MimeTypes.HEIC,
|
var previewFile = previewFiles[uri]
|
||||||
MimeTypes.HEIF,
|
if (previewFile == null) {
|
||||||
MimeTypes.MP4,
|
previewFile = createPreviewFile(context, uri)
|
||||||
MimeTypes.PSD_VND,
|
previewFiles[uri] = previewFile
|
||||||
MimeTypes.PSD_X,
|
|
||||||
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]
|
|
||||||
if (previewFile == null) {
|
|
||||||
previewFile = createPreviewFile(context, uri)
|
|
||||||
previewFiles[uri] = previewFile
|
|
||||||
}
|
|
||||||
Uri.fromFile(previewFile)
|
|
||||||
} else {
|
|
||||||
// small enough to be safe as it is
|
|
||||||
uri
|
|
||||||
}
|
}
|
||||||
|
Uri.fromFile(previewFile)
|
||||||
else ->
|
} else {
|
||||||
// *probably* safe
|
// small enough to be safe as it is
|
||||||
uri
|
uri
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// *probably* safe
|
||||||
|
uri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPreviewFile(context: Context, uri: Uri): File {
|
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 {
|
return StorageUtils.createTempFile(context).apply {
|
||||||
transferFrom(StorageUtils.openInputStream(context, uri), size)
|
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,7 @@ import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.toByteArray
|
import deckers.thibault.aves.utils.toByteArray
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import org.mp4parser.BasicContainer
|
import org.mp4parser.*
|
||||||
import org.mp4parser.Box
|
|
||||||
import org.mp4parser.Container
|
|
||||||
import org.mp4parser.IsoFile
|
|
||||||
import org.mp4parser.PropertyBoxParserImpl
|
|
||||||
import org.mp4parser.boxes.UnknownBox
|
import org.mp4parser.boxes.UnknownBox
|
||||||
import org.mp4parser.boxes.UserBox
|
import org.mp4parser.boxes.UserBox
|
||||||
import org.mp4parser.boxes.apple.AppleCoverBox
|
import org.mp4parser.boxes.apple.AppleCoverBox
|
||||||
|
@ -21,18 +17,8 @@ import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
||||||
import org.mp4parser.boxes.apple.AppleItemListBox
|
import org.mp4parser.boxes.apple.AppleItemListBox
|
||||||
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
||||||
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
||||||
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
import org.mp4parser.boxes.iso14496.part12.*
|
||||||
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.threegpp.ts26244.AuthorBox
|
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
||||||
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
|
|
||||||
import org.mp4parser.support.AbstractBox
|
import org.mp4parser.support.AbstractBox
|
||||||
import org.mp4parser.support.Matrix
|
import org.mp4parser.support.Matrix
|
||||||
import org.mp4parser.tools.Path
|
import org.mp4parser.tools.Path
|
||||||
|
@ -46,15 +32,6 @@ object Mp4ParserHelper {
|
||||||
// arbitrary size to detect boxes that may yield an OOM
|
// arbitrary size to detect boxes that may yield an OOM
|
||||||
private const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
|
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>> {
|
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// 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")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -143,35 +120,6 @@ object Mp4ParserHelper {
|
||||||
return false
|
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
|
// extensions
|
||||||
|
|
||||||
fun IsoFile.updateLocation(locationIso6709: String?) {
|
fun IsoFile.updateLocation(locationIso6709: String?) {
|
||||||
|
@ -311,18 +259,18 @@ object Mp4ParserHelper {
|
||||||
)
|
)
|
||||||
setBoxSkipper { type, size ->
|
setBoxSkipper { type, size ->
|
||||||
if (skippedTypes.contains(type)) return@setBoxSkipper true
|
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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserDataBox(
|
fun getUserData(
|
||||||
context: Context,
|
context: Context,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
): UserDataBox? {
|
): MutableMap<String, String> {
|
||||||
if (mimeType != MimeTypes.MP4) return null
|
val fields = HashMap<String, String>()
|
||||||
|
if (mimeType != MimeTypes.MP4) return fields
|
||||||
try {
|
try {
|
||||||
// we can skip uninteresting boxes with a seekable data source
|
// 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")
|
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||||
|
@ -331,7 +279,10 @@ object Mp4ParserHelper {
|
||||||
stream.channel.use { channel ->
|
stream.channel.use { channel ->
|
||||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||||
IsoFile(channel, metadataBoxParser()).use { isoFile ->
|
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 +292,10 @@ object Mp4ParserHelper {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
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>()
|
val fields = HashMap<String, String>()
|
||||||
for (box in container.boxes) {
|
for (box in container.boxes) {
|
||||||
if (box is AbstractBox && !box.isParsed) {
|
if (box is AbstractBox && !box.isParsed) {
|
||||||
|
@ -358,20 +309,9 @@ object Mp4ParserHelper {
|
||||||
is AppleGPSCoordinatesBox -> fields[key] = box.value
|
is AppleGPSCoordinatesBox -> fields[key] = box.value
|
||||||
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
|
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
|
||||||
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
|
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
|
||||||
is HandlerBox -> {}
|
is Utf8AppleDataBox -> fields[key] = box.value
|
||||||
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 HandlerBox -> {}
|
||||||
is MetaBox -> {
|
is MetaBox -> {
|
||||||
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
|
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
|
||||||
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
|
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
|
||||||
|
@ -396,8 +336,6 @@ object Mp4ParserHelper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Utf8AppleDataBox -> fields[key] = box.value
|
|
||||||
|
|
||||||
else -> fields[key] = box.toString()
|
else -> fields[key] = box.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -410,7 +348,6 @@ object Mp4ParserHelper {
|
||||||
"catg" -> "Category"
|
"catg" -> "Category"
|
||||||
"covr" -> "Cover Art"
|
"covr" -> "Cover Art"
|
||||||
"keyw" -> "Keyword"
|
"keyw" -> "Keyword"
|
||||||
"loci" -> "Location"
|
|
||||||
"mcvr" -> "Preview Image"
|
"mcvr" -> "Preview Image"
|
||||||
"pcst" -> "Podcast"
|
"pcst" -> "Podcast"
|
||||||
"SDLN" -> "Play Mode"
|
"SDLN" -> "Play Mode"
|
||||||
|
|
|
@ -9,14 +9,13 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import com.drew.imaging.jpeg.JpegSegmentType
|
import com.drew.imaging.jpeg.JpegSegmentType
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
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.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||||
|
@ -31,14 +30,10 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.indexOfBytes
|
import deckers.thibault.aves.utils.indexOfBytes
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
import java.io.EOFException
|
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
object MultiPage {
|
object MultiPage {
|
||||||
private val LOG_TAG = LogUtils.createTag<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()
|
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
|
||||||
|
|
||||||
// page info
|
// page info
|
||||||
|
@ -51,6 +46,14 @@ object MultiPage {
|
||||||
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
||||||
|
|
||||||
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
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 tracks = ArrayList<FieldMap>()
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
extractor.setDataSource(context, uri, null)
|
extractor.setDataSource(context, uri, null)
|
||||||
|
@ -86,26 +89,6 @@ object MultiPage {
|
||||||
return tracks
|
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 {
|
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
|
||||||
val mimeType = MimeTypes.JPEG
|
val mimeType = MimeTypes.JPEG
|
||||||
var rotationDegrees = 0
|
var rotationDegrees = 0
|
||||||
|
@ -153,31 +136,21 @@ object MultiPage {
|
||||||
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
|
// starts after `[APP2 marker (1 byte)] [segment size (2 bytes)] [MPF marker (4 bytes)]`
|
||||||
fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
|
fun getJpegMpfBaseOffset(context: Context, uri: Uri, sizeBytes: Long?): Int? {
|
||||||
val mimeType = MimeTypes.JPEG
|
val mimeType = MimeTypes.JPEG
|
||||||
val endMarker = 0xFF
|
|
||||||
val app2Marker = JpegSegmentType.APP2.byteValue
|
val app2Marker = JpegSegmentType.APP2.byteValue
|
||||||
val mpfMarker = "MPF".toByteArray() + 0x00
|
val mpfMarker = "MPF".toByteArray() + 0x00
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
var offset = 0
|
var offset = 0
|
||||||
val marker = ByteArray(4)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// look for APP2 marker (0xFFE2)
|
do {
|
||||||
var found = false
|
val b = input.read().toByte()
|
||||||
while (!found) {
|
|
||||||
var i = input.read()
|
|
||||||
if (i == -1) throw EOFException()
|
|
||||||
offset++
|
offset++
|
||||||
if (i == endMarker) {
|
} while (b != app2Marker)
|
||||||
i = input.read()
|
|
||||||
if (i == -1) throw EOFException()
|
|
||||||
offset++
|
|
||||||
found = i.toByte() == app2Marker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// skip 2 bytes for segment size
|
// skip 2 bytes for segment size
|
||||||
input.skip(2)
|
input.skip(2)
|
||||||
offset += 2
|
offset += 2
|
||||||
|
val marker = ByteArray(4)
|
||||||
input.read(marker, 0, marker.size)
|
input.read(marker, 0, marker.size)
|
||||||
offset += 4
|
offset += 4
|
||||||
if (marker.contentEquals(mpfMarker)) {
|
if (marker.contentEquals(mpfMarker)) {
|
||||||
|
@ -266,39 +239,70 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): 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 pages = ArrayList<FieldMap>()
|
val pages = ArrayList<FieldMap>()
|
||||||
getMotionPhotoVideoInfo(context, uri, mimeType, sizeBytes)?.let { videoInfo ->
|
val extractor = MediaExtractor()
|
||||||
// set the original image as the first and default track
|
var pfd: ParcelFileDescriptor? = null
|
||||||
var pageIndex = 0
|
try {
|
||||||
pages.add(
|
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
hashMapOf(
|
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||||
KEY_PAGE to pageIndex++,
|
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
||||||
KEY_MIME_TYPE to mimeType,
|
pfd?.fileDescriptor?.let { fd ->
|
||||||
KEY_IS_DEFAULT to true,
|
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
|
||||||
)
|
// set the original image as the first and default track
|
||||||
)
|
var pageIndex = 0
|
||||||
// add video tracks from the appended video
|
pages.add(
|
||||||
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
hashMapOf(
|
||||||
if (MimeTypes.isVideo(mime)) {
|
KEY_PAGE to pageIndex++,
|
||||||
val page: FieldMap = hashMapOf(
|
KEY_MIME_TYPE to mimeType,
|
||||||
KEY_PAGE to pageIndex++,
|
KEY_IS_DEFAULT to true,
|
||||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
)
|
||||||
KEY_IS_DEFAULT to false,
|
|
||||||
)
|
)
|
||||||
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
// add video tracks from the appended video
|
||||||
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
if (extractor.trackCount > 0) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
// only consider the first track to represent the appended video
|
||||||
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
val trackIndex = 0
|
||||||
|
try {
|
||||||
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
|
format.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,
|
||||||
|
)
|
||||||
|
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||||
|
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
||||||
|
}
|
||||||
|
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||||
|
pages.add(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 pages
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
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.
|
// 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),
|
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
||||||
|
@ -345,62 +349,6 @@ object MultiPage {
|
||||||
return offsetFromEnd
|
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 getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
|
|
|
@ -26,6 +26,7 @@ import pixy.meta.string.XMLUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
object PixyMetaHelper {
|
object PixyMetaHelper {
|
||||||
fun describe(input: InputStream): HashMap<String, String> {
|
fun describe(input: InputStream): HashMap<String, String> {
|
||||||
|
@ -81,18 +82,17 @@ object PixyMetaHelper {
|
||||||
output: OutputStream,
|
output: OutputStream,
|
||||||
iptcDataList: List<FieldMap>?,
|
iptcDataList: List<FieldMap>?,
|
||||||
) {
|
) {
|
||||||
val iptc: List<IPTCDataSet> = iptcDataList?.flatMap {
|
val iptc = iptcDataList?.flatMap {
|
||||||
val record = it["record"] as Int
|
val record = it["record"] as Int
|
||||||
val tag = it["tag"] as Int
|
val tag = it["tag"] as Int
|
||||||
val values = it["values"] as List<*>
|
val values = it["values"] as List<*>
|
||||||
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
||||||
} ?: ArrayList()
|
} ?: ArrayList<IPTCDataSet>()
|
||||||
Metadata.insertIPTC(input, output, iptc)
|
Metadata.insertIPTC(input, output, iptc)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
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(
|
fun setXmp(
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
output: OutputStream,
|
output: OutputStream,
|
||||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.metadata
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
||||||
|
|
||||||
|
|
|
@ -183,7 +183,7 @@ object GoogleXMP {
|
||||||
return offsetFromEnd
|
return offsetFromEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
|
fun updateTrailingVideoOffset(xmp: String, oldOffset: Int, newOffset: Int): String {
|
||||||
return xmp.replace(
|
return xmp.replace(
|
||||||
// GCamera motion photo
|
// GCamera motion photo
|
||||||
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
||||||
|
@ -195,6 +195,7 @@ object GoogleXMP {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
||||||
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
||||||
GoogleDeviceContainer().apply { findItems(meta) }
|
GoogleDeviceContainer().apply { findItems(meta) }
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
package deckers.thibault.aves.model
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
class AvesEntry(map: FieldMap) {
|
class AvesEntry(map: FieldMap) {
|
||||||
val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
|
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||||
val path = map[EntryFields.PATH] as String? // best effort to get local path
|
val path = map["path"] as String? // best effort to get local path
|
||||||
val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
|
val pageId = map["pageId"] as Int? // null means the main entry
|
||||||
val mimeType = map[EntryFields.MIME_TYPE] as String
|
val mimeType = map["mimeType"] as String
|
||||||
val width = map[EntryFields.WIDTH] as Int
|
val width = map["width"] as Int
|
||||||
val height = map[EntryFields.HEIGHT] as Int
|
val height = map["height"] as Int
|
||||||
val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
|
val rotationDegrees = map["rotationDegrees"] as Int
|
||||||
val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
|
val isFlipped = map["isFlipped"] as Boolean
|
||||||
val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
val sizeBytes = toLong(map["sizeBytes"])
|
||||||
val trashed = map[EntryFields.TRASHED] as Boolean
|
val trashed = map["trashed"] as Boolean
|
||||||
val trashPath = map[EntryFields.TRASH_PATH] as String?
|
val trashPath = map["trashPath"] as String?
|
||||||
|
|
||||||
private val isRotated: Boolean
|
private val isRotated: Boolean
|
||||||
get() = rotationDegrees % 180 == 90
|
get() = rotationDegrees % 180 == 90
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -5,7 +5,7 @@ import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.drew.metadata.avi.AviDirectory
|
import com.drew.metadata.avi.AviDirectory
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.jpeg.JpegDirectory
|
import com.drew.metadata.jpeg.JpegDirectory
|
||||||
|
@ -29,7 +29,6 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
class SourceEntry {
|
class SourceEntry {
|
||||||
private val origin: Int
|
private val origin: Int
|
||||||
|
@ -42,7 +41,7 @@ class SourceEntry {
|
||||||
private var sourceRotationDegrees: Int? = null
|
private var sourceRotationDegrees: Int? = null
|
||||||
private var sizeBytes: Long? = null
|
private var sizeBytes: Long? = null
|
||||||
private var dateAddedSecs: Long? = null
|
private var dateAddedSecs: Long? = null
|
||||||
private var dateModifiedMillis: Long? = null
|
private var dateModifiedSecs: Long? = null
|
||||||
private var sourceDateTakenMillis: Long? = null
|
private var sourceDateTakenMillis: Long? = null
|
||||||
private var durationMillis: Long? = null
|
private var durationMillis: Long? = null
|
||||||
|
|
||||||
|
@ -55,45 +54,45 @@ class SourceEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(map: FieldMap) {
|
constructor(map: FieldMap) {
|
||||||
origin = map[EntryFields.ORIGIN] as Int
|
origin = map["origin"] as Int
|
||||||
uri = (map[EntryFields.URI] as String).toUri()
|
uri = Uri.parse(map["uri"] as String)
|
||||||
path = map[EntryFields.PATH] as String?
|
path = map["path"] as String?
|
||||||
sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
|
sourceMimeType = map["sourceMimeType"] as String
|
||||||
width = map[EntryFields.WIDTH] as Int?
|
width = map["width"] as Int?
|
||||||
height = map[EntryFields.HEIGHT] as Int?
|
height = map["height"] as Int?
|
||||||
sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
|
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
|
||||||
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
sizeBytes = toLong(map["sizeBytes"])
|
||||||
title = map[EntryFields.TITLE] as String?
|
title = map["title"] as String?
|
||||||
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
|
dateAddedSecs = toLong(map["dateAddedSecs"])
|
||||||
dateModifiedMillis = toLong(map[EntryFields.DATE_MODIFIED_MILLIS])
|
dateModifiedSecs = toLong(map["dateModifiedSecs"])
|
||||||
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
|
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
|
||||||
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
|
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.path = path
|
||||||
this.title = title
|
this.title = title
|
||||||
this.sizeBytes = sizeBytes
|
this.sizeBytes = sizeBytes
|
||||||
this.dateModifiedMillis = dateModifiedMillis
|
this.dateModifiedSecs = dateModifiedSecs
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toMap(): FieldMap {
|
fun toMap(): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
EntryFields.ORIGIN to origin,
|
"origin" to origin,
|
||||||
EntryFields.URI to uri.toString(),
|
"uri" to uri.toString(),
|
||||||
EntryFields.PATH to path,
|
"path" to path,
|
||||||
EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
|
"sourceMimeType" to sourceMimeType,
|
||||||
EntryFields.WIDTH to width,
|
"width" to width,
|
||||||
EntryFields.HEIGHT to height,
|
"height" to height,
|
||||||
EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
|
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
||||||
EntryFields.SIZE_BYTES to sizeBytes,
|
"sizeBytes" to sizeBytes,
|
||||||
EntryFields.TITLE to title,
|
"title" to title,
|
||||||
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
|
"dateAddedSecs" to dateAddedSecs,
|
||||||
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
"dateModifiedSecs" to dateModifiedSecs,
|
||||||
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
|
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
||||||
EntryFields.DURATION_MILLIS to durationMillis,
|
"durationMillis" to durationMillis,
|
||||||
// only for map export
|
// only for map export
|
||||||
EntryFields.CONTENT_ID to contentId,
|
"contentId" to contentId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,8 +116,8 @@ class SourceEntry {
|
||||||
// metadata retrieval
|
// metadata retrieval
|
||||||
// expects entry with: uri, mimeType
|
// expects entry with: uri, mimeType
|
||||||
// finds: width, height, orientation/rotation, date, title, duration
|
// finds: width, height, orientation/rotation, date, title, duration
|
||||||
fun fillPreCatalogMetadata(context: Context): SourceEntry {
|
fun fillPreCatalogMetadata(context: Context, safe: Boolean): SourceEntry {
|
||||||
if (isSvg) return this
|
if (isSvg || safe) return this
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fillVideoByMediaMetadataRetriever(context)
|
fillVideoByMediaMetadataRetriever(context)
|
||||||
if (isSized && hasDuration) return this
|
if (isSized && hasDuration) return this
|
||||||
|
|
|
@ -6,7 +6,6 @@ import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -46,15 +45,14 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
path = path,
|
path = path,
|
||||||
title = file.name,
|
title = file.name,
|
||||||
sizeBytes = file.length(),
|
sizeBytes = file.length(),
|
||||||
dateModifiedMillis = file.lastModified(),
|
dateModifiedSecs = file.lastModified() / 1000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry.fillPreCatalogMetadata(context)
|
entry.fillPreCatalogMetadata(context, safe = false)
|
||||||
|
|
||||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||||
callback.onSuccess(entry.toMap())
|
callback.onSuccess(entry.toMap())
|
||||||
|
@ -89,9 +87,9 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
EntryFields.URI to Uri.fromFile(newFile).toString(),
|
"uri" to Uri.fromFile(newFile).toString(),
|
||||||
EntryFields.PATH to newFile.path,
|
"path" to newFile.path,
|
||||||
EntryFields.DATE_MODIFIED_MILLIS to newFile.lastModified(),
|
"dateModifiedSecs" to newFile.lastModified() / 1000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,8 +97,8 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
try {
|
try {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
newFields[EntryFields.DATE_MODIFIED_MILLIS] = file.lastModified()
|
newFields["dateModifiedSecs"] = file.lastModified() / 1000
|
||||||
newFields[EntryFields.SIZE_BYTES] = file.length()
|
newFields["sizeBytes"] = file.length()
|
||||||
}
|
}
|
||||||
callback.onSuccess(newFields)
|
callback.onSuccess(newFields)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
|
|
@ -11,11 +11,16 @@ import android.net.Uri
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
import com.bumptech.glide.Glide
|
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.FutureTarget
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
|
import deckers.thibault.aves.decoder.SvgImage
|
||||||
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
|
@ -33,7 +38,6 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictResolution
|
import deckers.thibault.aves.model.NameConflictResolution
|
||||||
|
@ -64,7 +68,6 @@ import java.nio.channels.Channels
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
|
|
||||||
abstract class ImageProvider {
|
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?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||||
|
@ -75,10 +78,10 @@ abstract class ImageProvider {
|
||||||
return if (StorageUtils.isInVault(context, path)) {
|
return if (StorageUtils.isInVault(context, path)) {
|
||||||
val uri = Uri.fromFile(File(path))
|
val uri = Uri.fromFile(File(path))
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
|
"origin" to SourceEntry.ORIGIN_VAULT,
|
||||||
EntryFields.URI to uri.toString(),
|
"uri" to uri.toString(),
|
||||||
EntryFields.CONTENT_ID to null,
|
"contentId" to null,
|
||||||
EntryFields.PATH to path,
|
"path" to path,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
||||||
|
@ -134,7 +137,8 @@ abstract class ImageProvider {
|
||||||
"success" to false,
|
"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 {
|
try {
|
||||||
var newFields: FieldMap = skippedFieldMap
|
var newFields: FieldMap = skippedFieldMap
|
||||||
if (!isCancelledOp()) {
|
if (!isCancelledOp()) {
|
||||||
|
@ -142,18 +146,16 @@ abstract class ImageProvider {
|
||||||
|
|
||||||
val oldFile = File(sourcePath)
|
val oldFile = File(sourcePath)
|
||||||
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
|
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
|
||||||
val defaultExtension = oldFile.extension
|
|
||||||
oldFile.parent?.let { dir ->
|
oldFile.parent?.let { dir ->
|
||||||
val resolution = resolveTargetFileNameWithoutExtension(
|
val resolution = resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper = activity,
|
contextWrapper = activity,
|
||||||
dir = dir,
|
dir = dir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
defaultExtension = defaultExtension,
|
|
||||||
conflictStrategy = NameConflictStrategy.RENAME,
|
conflictStrategy = NameConflictStrategy.RENAME,
|
||||||
)
|
)
|
||||||
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
|
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
|
||||||
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}"
|
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
|
||||||
val newFile = File(dir, targetFileName)
|
val newFile = File(dir, targetFileName)
|
||||||
if (oldFile != newFile) {
|
if (oldFile != newFile) {
|
||||||
newFields = renameSingle(
|
newFields = renameSingle(
|
||||||
|
@ -207,7 +209,6 @@ abstract class ImageProvider {
|
||||||
) {
|
) {
|
||||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||||
callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType"))
|
callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||||
|
@ -279,17 +280,11 @@ abstract class ImageProvider {
|
||||||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
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 resolution = resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper = activity,
|
contextWrapper = activity,
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = exportMimeType,
|
mimeType = exportMimeType,
|
||||||
defaultExtension = defaultExtension,
|
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||||
|
@ -322,12 +317,27 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) {
|
||||||
|
MultiPageImage(activity, sourceUri, sourceMimeType, 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.applicationContext)
|
target = Glide.with(activity.applicationContext)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
.apply(glideOptions)
|
||||||
.load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
|
.load(model)
|
||||||
.submit(targetWidthPx, targetHeightPx)
|
.submit(targetWidthPx, targetHeightPx)
|
||||||
|
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
||||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
|
@ -366,12 +376,11 @@ abstract class ImageProvider {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
defaultExtension = defaultExtension,
|
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
|
|
||||||
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
||||||
val targetUri = (newFields[EntryFields.URI] as String).toUri()
|
val targetUri = Uri.parse(newFields["uri"] as String)
|
||||||
if (writeMetadata) {
|
if (writeMetadata) {
|
||||||
copyMetadata(
|
copyMetadata(
|
||||||
context = activity,
|
context = activity,
|
||||||
|
@ -474,7 +483,6 @@ abstract class ImageProvider {
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = captureMimeType,
|
mimeType = captureMimeType,
|
||||||
defaultExtension = null,
|
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -561,42 +569,26 @@ 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
|
// returns available name to use, or `null` to skip it
|
||||||
suspend fun resolveTargetFileNameWithoutExtension(
|
suspend fun resolveTargetFileNameWithoutExtension(
|
||||||
contextWrapper: ContextWrapper,
|
contextWrapper: ContextWrapper,
|
||||||
dir: String,
|
dir: String,
|
||||||
desiredNameWithoutExtension: String,
|
desiredNameWithoutExtension: String,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
defaultExtension: String?,
|
|
||||||
conflictStrategy: NameConflictStrategy,
|
conflictStrategy: NameConflictStrategy,
|
||||||
): NameConflictResolution {
|
): NameConflictResolution {
|
||||||
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
|
var resolvedName: String? = desiredNameWithoutExtension
|
||||||
var resolvedName: String? = sanitizedNameWithoutExtension
|
|
||||||
var replacementFile: File? = null
|
var replacementFile: File? = null
|
||||||
|
|
||||||
val extension = extensionFor(mimeType, defaultExtension)
|
val extension = extensionFor(mimeType)
|
||||||
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
|
val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
|
||||||
when (conflictStrategy) {
|
when (conflictStrategy) {
|
||||||
NameConflictStrategy.RENAME -> {
|
NameConflictStrategy.RENAME -> {
|
||||||
var nameWithoutExtension = sanitizedNameWithoutExtension
|
var nameWithoutExtension = desiredNameWithoutExtension
|
||||||
var i = 0
|
var i = 0
|
||||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||||
i++
|
i++
|
||||||
nameWithoutExtension = "$sanitizedNameWithoutExtension ($i)"
|
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
||||||
}
|
}
|
||||||
resolvedName = nameWithoutExtension
|
resolvedName = nameWithoutExtension
|
||||||
}
|
}
|
||||||
|
@ -657,21 +649,19 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
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 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 {
|
try {
|
||||||
if (trailerVideoSize != null && isTrailerVideoValid) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
val videoByteSize = trailerVideoSize.toInt()
|
videoBytes = ByteArray(videoSize)
|
||||||
trailerVideoBytes = ByteArray(videoByteSize)
|
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, 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
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -691,31 +681,30 @@ abstract class ImageProvider {
|
||||||
try {
|
try {
|
||||||
edit(ExifInterface(editableFile))
|
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)
|
val editedMimeType = detectMimeType(context, Uri.fromFile(editableFile), mimeType)
|
||||||
if (editedMimeType != mimeType) {
|
if (editedMimeType != mimeType) {
|
||||||
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
|
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
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
|
// 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))
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trailerVideoBytes != null) {
|
if (videoBytes != null) {
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(trailerVideoBytes!!)
|
editableFile.appendBytes(videoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
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
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -743,21 +732,19 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
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 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 {
|
try {
|
||||||
if (trailerVideoSize != null && isTrailerVideoValid) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - trailerVideoSize).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
val videoByteSize = trailerVideoSize.toInt()
|
videoBytes = ByteArray(videoSize)
|
||||||
trailerVideoBytes = ByteArray(videoByteSize)
|
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, 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
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -793,20 +780,15 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editableFile.length() == 0L) {
|
if (videoBytes != null) {
|
||||||
callback.onFailure(Exception("editing IPTC yielded an empty file"))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trailerVideoBytes != null) {
|
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(trailerVideoBytes!!)
|
editableFile.appendBytes(videoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
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
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -876,7 +858,6 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
} catch (e: NoClassDefFoundError) {
|
} catch (e: NoClassDefFoundError) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return false
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return false
|
return false
|
||||||
|
@ -916,7 +897,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
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 {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
editXmpWithPixy(
|
editXmpWithPixy(
|
||||||
|
@ -934,16 +915,11 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editableFile.length() == 0L) {
|
|
||||||
callback.onFailure(Exception("editing XMP yielded an empty file"))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
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
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -1004,7 +980,7 @@ abstract class ImageProvider {
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
trailerOffset: Number?,
|
trailerOffset: Int?,
|
||||||
editedFile: File,
|
editedFile: File,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
@ -1019,7 +995,7 @@ abstract class ImageProvider {
|
||||||
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
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."
|
"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 ->
|
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||||
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
||||||
})
|
})
|
||||||
|
@ -1284,23 +1260,17 @@ abstract class ImageProvider {
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||||
if (trailerVideoSize == null) {
|
if (videoSize == null) {
|
||||||
callback.onFailure(Exception("failed to get trailer video size"))
|
callback.onFailure(Exception("failed to get trailer video size"))
|
||||||
return
|
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 {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
val inputStream = StorageUtils.openInputStream(context, uri)
|
val inputStream = StorageUtils.openInputStream(context, uri)
|
||||||
// partial copy
|
// partial copy
|
||||||
transferFrom(inputStream, originalFileSize - trailerVideoSize)
|
transferFrom(inputStream, originalFileSize - videoSize)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
|
@ -1335,8 +1305,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||||
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
|
@ -1352,16 +1321,11 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editableFile.length() == 0L) {
|
|
||||||
callback.onFailure(Exception("removing metadata yielded an empty file"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
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
|
return
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
|
|
@ -12,7 +12,6 @@ import android.graphics.BitmapFactory
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
@ -20,7 +19,6 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictStrategy
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
|
@ -51,14 +49,15 @@ import kotlin.coroutines.suspendCoroutine
|
||||||
class MediaStoreImageProvider : ImageProvider() {
|
class MediaStoreImageProvider : ImageProvider() {
|
||||||
fun fetchAll(
|
fun fetchAll(
|
||||||
context: Context,
|
context: Context,
|
||||||
knownEntries: Map<Long?, Long?>,
|
knownEntries: Map<Long?, Int?>,
|
||||||
directory: String?,
|
directory: String?,
|
||||||
|
safe: Boolean,
|
||||||
handleNewEntry: NewEntryHandler,
|
handleNewEntry: NewEntryHandler,
|
||||||
) {
|
) {
|
||||||
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory")
|
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory safe=$safe")
|
||||||
val isModified = fun(contentId: Long, dateModifiedMillis: Long): Boolean {
|
val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean {
|
||||||
val knownDate = knownEntries[contentId]
|
val knownDate = knownEntries[contentId]
|
||||||
return knownDate == null || knownDate < dateModifiedMillis
|
return knownDate == null || knownDate < dateModifiedSecs
|
||||||
}
|
}
|
||||||
val handleNew: NewEntryHandler
|
val handleNew: NewEntryHandler
|
||||||
var selection: String? = null
|
var selection: String? = null
|
||||||
|
@ -77,7 +76,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val parentCheckDirectory = removeTrailingSeparator(directory)
|
val parentCheckDirectory = removeTrailingSeparator(directory)
|
||||||
handleNew = { entry ->
|
handleNew = { entry ->
|
||||||
// skip entries in subfolders
|
// skip entries in subfolders
|
||||||
val path = entry[EntryFields.PATH] as String?
|
val path = entry["path"] as String?
|
||||||
if (path != null && File(path).parent == parentCheckDirectory) {
|
if (path != null && File(path).parent == parentCheckDirectory) {
|
||||||
handleNewEntry(entry)
|
handleNewEntry(entry)
|
||||||
}
|
}
|
||||||
|
@ -85,8 +84,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
} else {
|
} else {
|
||||||
handleNew = handleNewEntry
|
handleNew = handleNewEntry
|
||||||
}
|
}
|
||||||
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs)
|
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs, safe = safe)
|
||||||
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs)
|
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs, safe = safe)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the provided URI can point to the wrong media collection,
|
// the provided URI can point to the wrong media collection,
|
||||||
|
@ -96,7 +95,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
var found = false
|
var found = false
|
||||||
val fetched = arrayListOf<FieldMap>()
|
val fetched = arrayListOf<FieldMap>()
|
||||||
val id = uri.tryParseId()
|
val id = uri.tryParseId()
|
||||||
val alwaysValid: NewEntryChecker = fun(_: Long, _: Long): Boolean = true
|
val alwaysValid: NewEntryChecker = fun(_: Long, _: Int): Boolean = true
|
||||||
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
||||||
|
@ -209,6 +208,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
selection: String? = null,
|
selection: String? = null,
|
||||||
selectionArgs: Array<String>? = null,
|
selectionArgs: Array<String>? = null,
|
||||||
fileMimeType: String? = null,
|
fileMimeType: String? = null,
|
||||||
|
safe: Boolean = false,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
var found = false
|
var found = false
|
||||||
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||||
|
@ -227,8 +227,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
|
||||||
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
val dateAddedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
val dateModifiedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
|
||||||
|
|
||||||
// image & video for API >=29, only for images for API <29
|
// image & video for API >=29, only for images for API <29
|
||||||
|
@ -240,8 +240,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val id = cursor.getLong(idColumn)
|
val id = cursor.getLong(idColumn)
|
||||||
val dateModifiedMillis = cursor.getInt(dateModifiedSecsColumn) * 1000L
|
val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
|
||||||
if (isValidEntry(id, dateModifiedMillis)) {
|
if (isValidEntry(id, dateModifiedSecs)) {
|
||||||
// for multiple items, `contentUri` is the root without ID,
|
// for multiple items, `contentUri` is the root without ID,
|
||||||
// but for single items, `contentUri` already contains the 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, id)
|
||||||
|
@ -255,22 +255,21 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
if (mimeType == null) {
|
if (mimeType == null) {
|
||||||
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||||
} else {
|
} else {
|
||||||
val path = cursor.getString(pathColumn)
|
var entryMap: FieldMap = hashMapOf(
|
||||||
var entryFields: FieldMap = hashMapOf(
|
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
"uri" to itemUri.toString(),
|
||||||
EntryFields.URI to itemUri.toString(),
|
"path" to cursor.getString(pathColumn),
|
||||||
EntryFields.PATH to path,
|
"sourceMimeType" to mimeType,
|
||||||
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
"width" to width,
|
||||||
EntryFields.WIDTH to width,
|
"height" to height,
|
||||||
EntryFields.HEIGHT to height,
|
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||||
EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
"sizeBytes" to cursor.getLong(sizeColumn),
|
||||||
EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
|
"dateAddedSecs" to cursor.getInt(dateAddedColumn),
|
||||||
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedSecsColumn),
|
"dateModifiedSecs" to dateModifiedSecs,
|
||||||
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
|
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||||
EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
"durationMillis" to durationMillis,
|
||||||
EntryFields.DURATION_MILLIS to durationMillis,
|
|
||||||
// only for map export
|
// only for map export
|
||||||
EntryFields.CONTENT_ID to id,
|
"contentId" to id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (MimeTypes.isHeic(mimeType)) {
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
|
@ -286,8 +285,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
if (outWidth > 0 && outHeight > 0) {
|
if (outWidth > 0 && outHeight > 0) {
|
||||||
width = outWidth
|
width = outWidth
|
||||||
height = outHeight
|
height = outHeight
|
||||||
entryFields[EntryFields.WIDTH] = width
|
entryMap["width"] = width
|
||||||
entryFields[EntryFields.HEIGHT] = height
|
entryMap["height"] = height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
@ -303,13 +302,11 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// missing some attributes such as width, height, orientation.
|
// missing some attributes such as width, height, orientation.
|
||||||
// Also, the reported size of raw images is inconsistent across devices
|
// Also, the reported size of raw images is inconsistent across devices
|
||||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||||
val entry = SourceEntry(entryFields).fillPreCatalogMetadata(context)
|
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context, safe)
|
||||||
entryFields = entry.toMap()
|
entryMap = entry.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileModifiedDateMillis(path)?.let { entryFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
handleNewEntry(entryMap)
|
||||||
|
|
||||||
handleNewEntry(entryFields)
|
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -457,8 +454,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
effectiveTargetDir = targetDir
|
effectiveTargetDir = targetDir
|
||||||
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||||
if (!File(targetDir).exists()) {
|
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
|
// 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"))
|
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -482,62 +481,64 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
"success" to false,
|
"success" to false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
if (sourcePath != null) {
|
||||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
// 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
|
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
|
||||||
// - the underlying document provider controls the new file name
|
// - 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:
|
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
|
||||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
// with a path, and retrieve its content URI, but:
|
||||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||||
// - there is no documentation regarding support for usage with removable storage
|
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
// - there is no documentation regarding support for usage with removable storage
|
||||||
try {
|
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||||
val appDir = when {
|
try {
|
||||||
toBin -> StorageUtils.trashDirFor(activity, sourcePath ?: StorageUtils.getPrimaryVolumePath(activity))
|
val appDir = when {
|
||||||
toVault -> File(targetDir)
|
toBin -> StorageUtils.trashDirFor(activity, sourcePath)
|
||||||
else -> null
|
toVault -> File(targetDir)
|
||||||
}
|
else -> null
|
||||||
if (appDir != null) {
|
|
||||||
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
|
||||||
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
|
||||||
|
|
||||||
if (toVault) {
|
|
||||||
appDir.mkdirs()
|
|
||||||
}
|
}
|
||||||
}
|
if (appDir != null) {
|
||||||
|
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
||||||
|
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
||||||
|
|
||||||
if (effectiveTargetDir != null) {
|
if (toVault) {
|
||||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
appDir.mkdirs()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
callback.onSuccess(result)
|
||||||
}
|
}
|
||||||
|
@ -546,7 +547,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
|
|
||||||
private suspend fun moveSingle(
|
private suspend fun moveSingle(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
sourceFile: File?,
|
sourceFile: File,
|
||||||
sourceUri: Uri,
|
sourceUri: Uri,
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
|
@ -556,9 +557,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
toBin: Boolean,
|
toBin: Boolean,
|
||||||
): FieldMap {
|
): FieldMap {
|
||||||
val sourcePath = sourceFile?.path
|
val sourcePath = sourceFile.path
|
||||||
val sourceExtension = sourceFile?.extension
|
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
||||||
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
|
|
||||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||||
// nothing to do unless it's a renamed copy
|
// nothing to do unless it's a renamed copy
|
||||||
return skippedFieldMap
|
return skippedFieldMap
|
||||||
|
@ -570,7 +570,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
dir = targetDir,
|
dir = targetDir,
|
||||||
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
defaultExtension = sourceExtension,
|
|
||||||
conflictStrategy = nameConflictStrategy,
|
conflictStrategy = nameConflictStrategy,
|
||||||
)
|
)
|
||||||
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
|
||||||
|
@ -582,7 +581,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
defaultExtension = sourceExtension,
|
|
||||||
) { output: OutputStream ->
|
) { output: OutputStream ->
|
||||||
try {
|
try {
|
||||||
sourceDocFile.copyTo(output)
|
sourceDocFile.copyTo(output)
|
||||||
|
@ -604,8 +602,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
return if (toBin) {
|
return if (toBin) {
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
EntryFields.TRASHED to true,
|
"trashed" to true,
|
||||||
EntryFields.TRASH_PATH to targetPath,
|
"trashPath" to targetPath,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
scanNewPath(activity, targetPath, mimeType)
|
scanNewPath(activity, targetPath, mimeType)
|
||||||
|
@ -618,23 +616,24 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
targetNameWithoutExtension: String,
|
targetNameWithoutExtension: String,
|
||||||
defaultExtension: String?,
|
|
||||||
write: (OutputStream) -> Unit,
|
write: (OutputStream) -> Unit,
|
||||||
): String {
|
): String {
|
||||||
if (StorageUtils.isInVault(activity, targetDir)) {
|
if (StorageUtils.isInVault(activity, targetDir)) {
|
||||||
return insertByFile(
|
return insertByFile(
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
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(
|
return insertByMediaStore(
|
||||||
activity = activity,
|
activity = activity,
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
|
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
|
||||||
write = write,
|
write = write,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -646,18 +645,10 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir = targetDir,
|
targetDir = targetDir,
|
||||||
targetDirDocFile = targetDirDocFile,
|
targetDirDocFile = targetDirDocFile,
|
||||||
targetNameWithoutExtension = targetNameWithoutExtension,
|
targetNameWithoutExtension = targetNameWithoutExtension,
|
||||||
defaultExtension = defaultExtension,
|
|
||||||
write = write,
|
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(
|
private fun insertByFile(
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetFileName: String,
|
targetFileName: String,
|
||||||
|
@ -705,7 +696,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
targetDir: String,
|
targetDir: String,
|
||||||
targetDirDocFile: DocumentFileCompat?,
|
targetDirDocFile: DocumentFileCompat?,
|
||||||
targetNameWithoutExtension: String,
|
targetNameWithoutExtension: String,
|
||||||
defaultExtension: String?,
|
|
||||||
write: (OutputStream) -> Unit,
|
write: (OutputStream) -> Unit,
|
||||||
): String {
|
): String {
|
||||||
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
|
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
|
||||||
|
@ -714,22 +704,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
var targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||||
var targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
val 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
targetDocFile.openOutputStream().use(write)
|
targetDocFile.openOutputStream().use(write)
|
||||||
|
@ -846,32 +822,18 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
try {
|
try {
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
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.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
return@scanFile
|
return@scanFile
|
||||||
}
|
}
|
||||||
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
|
||||||
callback.onSuccess(newFields)
|
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) {
|
private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
val delayMillis = 500L
|
val delayMillis = 500L
|
||||||
|
@ -949,15 +911,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
val newFields = hashMapOf<String, Any?>(
|
val newFields = hashMapOf<String, Any?>(
|
||||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
EntryFields.URI to uri.toString(),
|
"uri" to uri.toString(),
|
||||||
EntryFields.CONTENT_ID to uri.tryParseId(),
|
"contentId" to uri.tryParseId(),
|
||||||
EntryFields.PATH to path,
|
"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_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = 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_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
|
|
||||||
return newFields
|
return newFields
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -1068,4 +1029,4 @@ object MediaColumns {
|
||||||
|
|
||||||
typealias NewEntryHandler = (entry: FieldMap) -> Unit
|
typealias NewEntryHandler = (entry: FieldMap) -> Unit
|
||||||
|
|
||||||
private typealias NewEntryChecker = (contentId: Long, dateModifiedMillis: Long) -> Boolean
|
private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean
|
|
@ -7,7 +7,6 @@ import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.model.EntryFields
|
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -44,9 +43,9 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val fields: FieldMap = hashMapOf(
|
val fields: FieldMap = hashMapOf(
|
||||||
EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||||
EntryFields.URI to uri.toString(),
|
"uri" to uri.toString(),
|
||||||
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
"sourceMimeType" to mimeType,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
// some providers do not provide the mandatory `OpenableColumns`
|
// some providers do not provide the mandatory `OpenableColumns`
|
||||||
|
@ -54,11 +53,11 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
// e.g. `content://mms/part/[id]` on Android KitKat
|
// e.g. `content://mms/part/[id]` on Android KitKat
|
||||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields[EntryFields.TITLE] = cursor.getString(it) }
|
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
|
||||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields[EntryFields.PATH] = cursor.getString(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`
|
// 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.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields["sourceMimeType"] = cursor.getString(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -66,12 +65,12 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
|
if (fields["sourceMimeType"] == null) {
|
||||||
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
|
val entry = SourceEntry(fields).fillPreCatalogMetadata(context, safe = false)
|
||||||
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
if (allowUnsized || entry.isSized || entry.isSvg || entry.isVideo) {
|
||||||
callback.onSuccess(entry.toMap())
|
callback.onSuccess(entry.toMap())
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,121 +2,27 @@ package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.ColorSpace
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Half
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
object BitmapUtils {
|
object BitmapUtils {
|
||||||
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
|
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
|
||||||
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
|
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 freeBaos = ArrayList<ByteArrayOutputStream>()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private const val INT_BYTE_SIZE = 4
|
const val ARGB_8888_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
|
val stream: ByteArrayOutputStream
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
// this method is called a lot, so we try and reuse output streams
|
// this method is called a lot, so we try and reuse output streams
|
||||||
|
@ -128,17 +34,19 @@ object BitmapUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
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
|
// `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
|
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
|
||||||
if (canHaveAlpha && bitmap.hasAlpha()) {
|
if (canHaveAlpha && hasAlpha()) {
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
|
||||||
} else {
|
} 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()
|
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")
|
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,107 +62,6 @@ object BitmapUtils {
|
||||||
return null
|
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? {
|
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||||
|
|
|
@ -90,7 +90,12 @@ object BmpWriter {
|
||||||
|
|
||||||
var column = 0
|
var column = 0
|
||||||
while (column < biWidth) {
|
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]
|
value = pixels[column]
|
||||||
// blue: [0], green: [1], red: [2]
|
// blue: [0], green: [1], red: [2]
|
||||||
rgb[0] = (value and 0xFF).toByte()
|
rgb[0] = (value and 0xFF).toByte()
|
||||||
|
|
|
@ -8,8 +8,6 @@ fun ByteBuffer.toByteArray(): ByteArray {
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
|
|
||||||
|
|
||||||
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
|
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
|
||||||
|
|
||||||
fun Byte.toHex(): String = "%02x".format(this)
|
fun Byte.toHex(): String = "%02x".format(this)
|
|
@ -20,7 +20,6 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boyer-Moore algorithm for pattern searching
|
// 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 {
|
fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int {
|
||||||
val n: Int = this.size
|
val n: Int = this.size
|
||||||
val m: Int = pattern.size
|
val m: Int = pattern.size
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -5,5 +5,5 @@ import kotlin.math.pow
|
||||||
|
|
||||||
object MathUtils {
|
object MathUtils {
|
||||||
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble())
|
||||||
fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt()
|
private fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt()
|
||||||
}
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
|
|
||||||
object MimeTypes {
|
object MimeTypes {
|
||||||
const val ANY = "*/*"
|
const val ANY = "*/*"
|
||||||
|
@ -17,8 +17,8 @@ object MimeTypes {
|
||||||
private const val ICO = "image/x-icon"
|
private const val ICO = "image/x-icon"
|
||||||
const val JPEG = "image/jpeg"
|
const val JPEG = "image/jpeg"
|
||||||
const val PNG = "image/png"
|
const val PNG = "image/png"
|
||||||
const val PSD_VND = "image/vnd.adobe.photoshop"
|
private const val PSD_VND = "image/vnd.adobe.photoshop"
|
||||||
const val PSD_X = "image/x-photoshop"
|
private const val PSD_X = "image/x-photoshop"
|
||||||
const val TIFF = "image/tiff"
|
const val TIFF = "image/tiff"
|
||||||
private const val WBMP = "image/vnd.wap.wbmp"
|
private const val WBMP = "image/vnd.wap.wbmp"
|
||||||
const val WEBP = "image/webp"
|
const val WEBP = "image/webp"
|
||||||
|
@ -84,11 +84,11 @@ object MimeTypes {
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
|
// as of Flutter v3.16.4, with additional custom handling for SVG
|
||||||
// while handling still PNG and JPEG on Android for color space and config conversion
|
fun canDecodeWithFlutter(mimeType: String, pageId: Int?, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||||
fun canDecodeWithFlutter(mimeType: String, isAnimated: Boolean) = when (mimeType) {
|
|
||||||
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||||
JPEG, PNG -> isAnimated
|
JPEG -> (pageId ?: 0) == 0
|
||||||
|
PNG -> (rotationDegrees ?: 0) == 0 && !(isFlipped ?: false)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,12 +99,9 @@ object MimeTypes {
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of `ExifInterface` v1.4.0-alpha01, `isSupportedMimeType` reports
|
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
|
||||||
// no support for AVIF/TIFF images, but it can actually open them (maybe other formats too)
|
// no support for TIFF images, but it can actually open them (maybe other formats too)
|
||||||
fun canReadWithExifInterface(mimeType: String, strict: Boolean = true): Boolean {
|
fun canReadWithExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
|
||||||
if (!strict) return true
|
|
||||||
return ExifInterface.isSupportedMimeType(mimeType) || mimeType == AVIF
|
|
||||||
}
|
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
|
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
|
||||||
|
@ -146,7 +143,7 @@ object MimeTypes {
|
||||||
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
true
|
true
|
||||||
} else when (mimeType) {
|
} else when (mimeType) {
|
||||||
AVIF, DNG, DNG_ADOBE, HEIC, HEIF, PNG, WEBP -> true
|
DNG, DNG_ADOBE, HEIC, HEIF, PNG, WEBP -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -163,24 +160,12 @@ object MimeTypes {
|
||||||
|
|
||||||
// among other refs:
|
// among other refs:
|
||||||
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
||||||
fun extensionFor(mimeType: String, defaultExtension: String?): String = when (mimeType) {
|
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||||
AVI, AVI_VND -> ".avi"
|
AVI, AVI_VND -> ".avi"
|
||||||
DNG, DNG_ADOBE -> ".dng"
|
|
||||||
HEIC, HEIF -> ".heif"
|
HEIC, HEIF -> ".heif"
|
||||||
MP2T, MP2TS -> ".m2ts"
|
MP2T, MP2TS -> ".m2ts"
|
||||||
PSD_VND, PSD_X -> ".psd"
|
PSD_VND, PSD_X -> ".psd"
|
||||||
else -> {
|
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
||||||
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: defaultExtension
|
|
||||||
if (ext != null) {
|
|
||||||
// fallback to provided extension when available,
|
|
||||||
// typically the original file extension when moving/renaming
|
|
||||||
if (ext.startsWith(".")) ext else ".$ext"
|
|
||||||
} else {
|
|
||||||
// fallback to generic extensions,
|
|
||||||
// as incorrect file extensions are better than none for media detection
|
|
||||||
if (isVideo(mimeType)) ".mp4" else ".jpg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||||
|
|
|
@ -17,7 +17,6 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
object PermissionManager {
|
object PermissionManager {
|
||||||
|
@ -87,7 +86,6 @@ object PermissionManager {
|
||||||
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
||||||
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
|
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
|
||||||
val accessibleDirs = getAccessibleDirs(context)
|
val accessibleDirs = getAccessibleDirs(context)
|
||||||
val restrictedPrimaryDirectoriesLower = getRestrictedPrimaryDirectories().map { it.lowercase(Locale.ROOT) }
|
|
||||||
|
|
||||||
// find set of inaccessible directories for each volume
|
// find set of inaccessible directories for each volume
|
||||||
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
||||||
|
@ -98,12 +96,12 @@ object PermissionManager {
|
||||||
segments.volumePath?.let { volumePath ->
|
segments.volumePath?.let { volumePath ->
|
||||||
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// request primary directory on volume from Android 11 (API 30)
|
// request primary directory on volume from Android 11
|
||||||
val relativeDir = segments.relativeDir
|
val relativeDir = segments.relativeDir
|
||||||
if (relativeDir != null) {
|
if (relativeDir != null) {
|
||||||
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
||||||
val primaryDir = dirSegments.firstOrNull()
|
val primaryDir = dirSegments.firstOrNull()
|
||||||
if (dirSegments.size > 1 && restrictedPrimaryDirectoriesLower.contains(primaryDir?.lowercase(Locale.ROOT))) {
|
if (getRestrictedPrimaryDirectories().contains(primaryDir) && dirSegments.size > 1) {
|
||||||
// request secondary directory (if any) for restricted primary directory
|
// request secondary directory (if any) for restricted primary directory
|
||||||
val dir = dirSegments.take(2).joinToString(File.separator)
|
val dir = dirSegments.take(2).joinToString(File.separator)
|
||||||
// only register directories that exist on storage, so they can be selected for access grant
|
// only register directories that exist on storage, so they can be selected for access grant
|
||||||
|
@ -142,11 +140,10 @@ object PermissionManager {
|
||||||
|
|
||||||
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
val insertionDirsLower = MEDIA_STORE_INSERTION_PRIMARY_DIRS.map { it.lowercase(Locale.ROOT) }
|
|
||||||
directories.all {
|
directories.all {
|
||||||
val relativeDir = it["relativeDir"] as String
|
val relativeDir = it["relativeDir"] as String
|
||||||
val segments = relativeDir.split(File.separator)
|
val segments = relativeDir.split(File.separator)
|
||||||
segments.isNotEmpty() && insertionDirsLower.contains(segments.first().lowercase(Locale.ROOT))
|
segments.isNotEmpty() && MEDIA_STORE_INSERTION_PRIMARY_DIRS.contains(segments.first())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
@ -175,6 +172,7 @@ object PermissionManager {
|
||||||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||||
accessibleDirs.addAll(context.getExternalFilesDirs(null).filterNotNull().map { it.path })
|
accessibleDirs.addAll(context.getExternalFilesDirs(null).filterNotNull().map { it.path })
|
||||||
|
|
||||||
|
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
|
||||||
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
|
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
|
||||||
// from API 30 / Android 11 / R, any storage requires access permission
|
// from API 30 / Android 11 / R, any storage requires access permission
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||||
|
|
|
@ -9,14 +9,13 @@ import android.content.pm.PackageManager
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||||
|
@ -82,8 +81,7 @@ object StorageUtils {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val trashDir = File(externalFilesDir, "trash")
|
val trashDir = File(externalFilesDir, "trash")
|
||||||
trashDir.mkdirs()
|
if (!trashDir.exists() && !trashDir.mkdirs()) {
|
||||||
if (!trashDir.exists()) {
|
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -122,6 +120,10 @@ object StorageUtils {
|
||||||
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getDownloadDirPath(context: Context, anyPath: String): String? {
|
||||||
|
return getVolumePath(context, anyPath)?.let { volumePath -> ensureTrailingSeparator(File(volumePath, Environment.DIRECTORY_DOWNLOADS).path) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
||||||
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
||||||
|
|
||||||
|
@ -230,7 +232,7 @@ object StorageUtils {
|
||||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||||
// /storage/emulated/[0,1,2,...]/
|
// /storage/emulated/[0,1,2,...]/
|
||||||
val path = getPrimaryVolumePath(context)
|
val path = getPrimaryVolumePath(context)
|
||||||
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { it.isDigitsOnly() } ?: ""
|
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
||||||
if (rawUserId.isEmpty()) {
|
if (rawUserId.isEmpty()) {
|
||||||
paths.add(rawEmulatedStorageTarget)
|
paths.add(rawEmulatedStorageTarget)
|
||||||
} else {
|
} else {
|
||||||
|
@ -501,8 +503,7 @@ object StorageUtils {
|
||||||
parentFile
|
parentFile
|
||||||
} else {
|
} else {
|
||||||
val directory = File(cleanDirPath)
|
val directory = File(cleanDirPath)
|
||||||
directory.mkdirs()
|
if (!directory.exists() && !directory.mkdirs()) {
|
||||||
if (!directory.exists()) {
|
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -564,7 +565,7 @@ object StorageUtils {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||||
val path = uri.path
|
val path = uri.path
|
||||||
path ?: return uri
|
path ?: return uri
|
||||||
// from Android 11 (API 30), accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
|
// from Android 11, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
|
||||||
if (path.startsWith(IMAGE_PATH_ROOT) || path.startsWith(VIDEO_PATH_ROOT)) {
|
if (path.startsWith(IMAGE_PATH_ROOT) || path.startsWith(VIDEO_PATH_ROOT)) {
|
||||||
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
|
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
|
||||||
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
|
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
@ -639,7 +640,7 @@ object StorageUtils {
|
||||||
|
|
||||||
// strip user info, if any
|
// strip user info, if any
|
||||||
// e.g. `content://0@media/...`
|
// e.g. `content://0@media/...`
|
||||||
private fun stripMediaUriUserInfo(uri: Uri) = uri.toString().replaceFirst("${uri.userInfo}@", "").toUri()
|
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
||||||
|
|
||||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||||
val effectiveUri = getOriginalUri(context, uri)
|
val effectiveUri = getOriginalUri(context, uri)
|
||||||
|
@ -715,8 +716,7 @@ object StorageUtils {
|
||||||
|
|
||||||
fun createTempFile(context: Context, extension: String? = null): File {
|
fun createTempFile(context: Context, extension: String? = null): File {
|
||||||
val directory = getTempDirectory(context)
|
val directory = getTempDirectory(context)
|
||||||
directory.mkdirs()
|
if (!directory.exists() && !directory.mkdirs()) {
|
||||||
if (!directory.exists()) {
|
|
||||||
throw IOException("failed to create directories at path=$directory")
|
throw IOException("failed to create directories at path=$directory")
|
||||||
}
|
}
|
||||||
val tempFile = File.createTempFile("aves", extension, directory)
|
val tempFile = File.createTempFile("aves", extension, directory)
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="48dp"
|
||||||
|
android:height="48dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/ic_shortcut_background"
|
||||||
|
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
||||||
|
<group
|
||||||
|
android:translateX="12"
|
||||||
|
android:translateY="12">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/ic_shortcut_foreground"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM19.46,9.12l-2.78,1.15c-0.51,-1.36 -1.58,-2.44 -2.95,-2.94l1.15,-2.78C16.98,5.35 18.65,7.02 19.46,9.12zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3s3,1.34 3,3S13.66,15 12,15zM9.13,4.54l1.17,2.78c-1.38,0.5 -2.47,1.59 -2.98,2.97L4.54,9.13C5.35,7.02 7.02,5.35 9.13,4.54zM4.54,14.87l2.78,-1.15c0.51,1.38 1.59,2.46 2.97,2.96l-1.17,2.78C7.02,18.65 5.35,16.98 4.54,14.87zM14.88,19.46l-1.15,-2.78c1.37,-0.51 2.45,-1.59 2.95,-2.97l2.78,1.17C18.65,16.98 16.98,18.65 14.88,19.46z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
|
@ -0,0 +1,16 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:tint="@color/ic_shortcut_foreground"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:scaleX="1.7226"
|
||||||
|
android:scaleY="1.7226"
|
||||||
|
android:translateX="33.3288"
|
||||||
|
android:translateY="33.3288">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10s10,-4.48 10,-10C22,6.48 17.52,2 12,2zM19.46,9.12l-2.78,1.15c-0.51,-1.36 -1.58,-2.44 -2.95,-2.94l1.15,-2.78C16.98,5.35 18.65,7.02 19.46,9.12zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3s3,1.34 3,3S13.66,15 12,15zM9.13,4.54l1.17,2.78c-1.38,0.5 -2.47,1.59 -2.98,2.97L4.54,9.13C5.35,7.02 7.02,5.35 9.13,4.54zM4.54,14.87l2.78,-1.15c0.51,1.38 1.59,2.46 2.97,2.96l-1.17,2.78C7.02,18.65 5.35,16.98 4.54,14.87zM14.88,19.46l-1.15,-2.78c1.37,-0.51 2.45,-1.59 2.95,-2.97l2.78,1.17C18.65,16.98 16.98,18.65 14.88,19.46z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
|
@ -1,16 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="48dp"
|
|
||||||
android:height="48dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/ic_shortcut_background"
|
|
||||||
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
|
||||||
<group
|
|
||||||
android:translateX="12"
|
|
||||||
android:translateY="12">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/ic_shortcut_foreground"
|
|
||||||
android:pathData="M20.5,3l-0.16,0.03L15,5.1 9,3 3.36,4.9c-0.21,0.07 -0.36,0.25 -0.36,0.48L3,20.5c0,0.28 0.22,0.5 0.5,0.5l0.16,-0.03L9,18.9l6,2.1 5.64,-1.9c0.21,-0.07 0.36,-0.25 0.36,-0.48L21,3.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM10,5.47l4,1.4v11.66l-4,-1.4L10,5.47zM5,6.46l3,-1.01v11.7l-3,1.16L5,6.46zM19,17.54l-3,1.01L16,6.86l3,-1.16v11.84z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
|
@ -1,16 +0,0 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:tint="@color/ic_shortcut_foreground"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<group
|
|
||||||
android:scaleX="1.7226"
|
|
||||||
android:scaleY="1.7226"
|
|
||||||
android:translateX="33.3288"
|
|
||||||
android:translateY="33.3288">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20.5,3l-0.16,0.03L15,5.1 9,3 3.36,4.9c-0.21,0.07 -0.36,0.25 -0.36,0.48L3,20.5c0,0.28 0.22,0.5 0.5,0.5l0.16,-0.03L9,18.9l6,2.1 5.64,-1.9c0.21,-0.07 0.36,-0.25 0.36,-0.48L21,3.5c0,-0.28 -0.22,-0.5 -0.5,-0.5zM10,5.47l4,1.4v11.66l-4,-1.4L10,5.47zM5,6.46l3,-1.01v11.7l-3,1.16L5,6.46zM19,17.54l-3,1.01L16,6.86l3,-1.16v11.84z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_shortcut_background" />
|
<background android:drawable="@color/ic_shortcut_background" />
|
||||||
<foreground android:drawable="@drawable/ic_shortcut_map_foreground" />
|
<foreground android:drawable="@drawable/ic_shortcut_safe_mode_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
|
@ -7,6 +7,6 @@
|
||||||
<string name="analysis_channel_name">فحص الوسائط</string>
|
<string name="analysis_channel_name">فحص الوسائط</string>
|
||||||
<string name="analysis_notification_default_title">يتم فحص الوسائط</string>
|
<string name="analysis_notification_default_title">يتم فحص الوسائط</string>
|
||||||
<string name="analysis_notification_action_stop">إيقاف</string>
|
<string name="analysis_notification_action_stop">إيقاف</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">الوضع الآمن</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="map_shortcut_short_label">خريطة</string>
|
|
||||||
</resources>
|
</resources>
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Aves</string>
|
|
||||||
<string name="app_widget_label">Şəkil Çərçivəsi</string>
|
|
||||||
<string name="wallpaper">Divar kağızı</string>
|
|
||||||
<string name="map_shortcut_short_label">Xəritə</string>
|
|
||||||
<string name="search_shortcut_short_label">Axtarış</string>
|
|
||||||
<string name="videos_shortcut_short_label">Videolar</string>
|
|
||||||
<string name="analysis_channel_name">Medianı yoxla</string>
|
|
||||||
<string name="analysis_notification_default_title">Media yoxlanılır</string>
|
|
||||||
<string name="analysis_notification_action_stop">Dayandır</string>
|
|
||||||
</resources>
|
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">𐑱𐑝𐑰𐑟</string>
|
|
||||||
<string name="app_widget_label">𐑓𐑴𐑑𐑴 𐑓𐑮𐑱𐑥</string>
|
|
||||||
<string name="wallpaper">𐑢𐑷𐑤𐑐𐑱𐑐𐑼</string>
|
|
||||||
<string name="map_shortcut_short_label">𐑥𐑨𐑐</string>
|
|
||||||
<string name="search_shortcut_short_label">𐑕𐑻𐑗</string>
|
|
||||||
<string name="videos_shortcut_short_label">𐑝𐑦𐑛𐑦𐑴𐑟</string>
|
|
||||||
<string name="analysis_channel_name">𐑥𐑰𐑛𐑾 𐑕𐑒𐑨𐑯</string>
|
|
||||||
<string name="analysis_notification_default_title">𐑕𐑒𐑨𐑯𐑦𐑙 𐑥𐑰𐑛𐑾</string>
|
|
||||||
<string name="analysis_notification_action_stop">𐑕𐑑𐑪𐑐</string>
|
|
||||||
</resources>
|
|
|
@ -6,6 +6,7 @@
|
||||||
<string name="analysis_notification_default_title">Сканаванне носьбітаў</string>
|
<string name="analysis_notification_default_title">Сканаванне носьбітаў</string>
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="app_widget_label">Фотарамка</string>
|
<string name="app_widget_label">Фотарамка</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">Бяспечны рэжым</string>
|
||||||
<string name="search_shortcut_short_label">Пошук</string>
|
<string name="search_shortcut_short_label">Пошук</string>
|
||||||
<string name="analysis_notification_action_stop">Стоп</string>
|
<string name="analysis_notification_action_stop">Стоп</string>
|
||||||
</resources>
|
</resources>
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Aves</string>
|
|
||||||
<string name="app_widget_label">Фоторамка</string>
|
|
||||||
<string name="wallpaper">Тапет</string>
|
|
||||||
<string name="map_shortcut_short_label">Карта</string>
|
|
||||||
<string name="search_shortcut_short_label">Търсене</string>
|
|
||||||
<string name="analysis_channel_name">Сканиране медия</string>
|
|
||||||
<string name="analysis_notification_default_title">Сканиране медия</string>
|
|
||||||
<string name="analysis_notification_action_stop">Стоп</string>
|
|
||||||
<string name="videos_shortcut_short_label">Видео</string>
|
|
||||||
</resources>
|
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_action_stop">থামান</string>
|
<string name="analysis_notification_action_stop">থামান</string>
|
||||||
<string name="app_name">আভেস</string>
|
<string name="app_name">আভেস</string>
|
||||||
<string name="app_widget_label">ছবির ফ্রেম</string>
|
<string name="app_widget_label">ছবির ফ্রেম</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">নিরাপদ মোড</string>
|
||||||
</resources>
|
</resources>
|
|
@ -3,10 +3,10 @@
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="app_widget_label">Marc de foto</string>
|
<string name="app_widget_label">Marc de foto</string>
|
||||||
<string name="wallpaper">Fons de pantalla</string>
|
<string name="wallpaper">Fons de pantalla</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">Mode segur</string>
|
||||||
<string name="search_shortcut_short_label">Buscar</string>
|
<string name="search_shortcut_short_label">Buscar</string>
|
||||||
<string name="videos_shortcut_short_label">Vídeos</string>
|
<string name="videos_shortcut_short_label">Vídeos</string>
|
||||||
<string name="analysis_channel_name">Exploració de mitjans</string>
|
<string name="analysis_channel_name">Exploració de mitjans</string>
|
||||||
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
<string name="analysis_notification_default_title">Explorant mitjans</string>
|
||||||
<string name="analysis_notification_action_stop">Atura</string>
|
<string name="analysis_notification_action_stop">Atura</string>
|
||||||
<string name="map_shortcut_short_label">Mapa</string>
|
</resources>
|
||||||
</resources>
|
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_channel_name">گەڕان لە پەڕگە</string>
|
<string name="analysis_channel_name">گەڕان لە پەڕگە</string>
|
||||||
<string name="analysis_notification_default_title">لە پەڕگەکان دەگەڕێت</string>
|
<string name="analysis_notification_default_title">لە پەڕگەکان دەگەڕێت</string>
|
||||||
<string name="analysis_notification_action_stop">وەستاندن</string>
|
<string name="analysis_notification_action_stop">وەستاندن</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">دۆخی پارێزراو</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,5 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
<string name="analysis_notification_default_title">Prohledávání médií</string>
|
||||||
<string name="analysis_notification_action_stop">Zastavit</string>
|
<string name="analysis_notification_action_stop">Zastavit</string>
|
||||||
<string name="app_widget_label">Fotorámeček</string>
|
<string name="app_widget_label">Fotorámeček</string>
|
||||||
<string name="map_shortcut_short_label">Mapa</string>
|
<string name="safe_mode_shortcut_short_label">Bezpečný režim</string>
|
||||||
</resources>
|
</resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue