Compare commits

..

52 commits

Author SHA1 Message Date
Thibault Deckers
7bf59106f9 Merge branch 'develop' 2025-02-06 17:19:03 +01:00
Thibault Deckers
94ad4c01f4 Merge branch 'develop' 2025-01-13 10:45:04 +01:00
Thibault Deckers
6803dae438 Merge branch 'develop' 2025-01-05 16:35:42 +01:00
Thibault Deckers
3ebb6e8cbf Merge branch 'develop' 2024-12-19 21:56:08 +01:00
Thibault Deckers
c5f06baef1 Merge branch 'develop' 2024-12-11 21:51:37 +01:00
Thibault Deckers
1d7deac84d Merge branch 'develop' 2024-11-24 23:16:41 +01:00
Thibault Deckers
c62e68399c Merge branch 'develop' 2024-11-18 23:46:35 +01:00
Thibault Deckers
530bd1a8b0 Merge branch 'develop' 2024-10-30 23:14:23 +01:00
Thibault Deckers
1c1be8ba1c Merge branch 'develop' 2024-10-10 19:10:20 +02:00
Thibault Deckers
e6afda2bc9 Merge branch 'develop' 2024-10-09 01:04:19 +02:00
Thibault Deckers
35aeefe34b Merge branch 'develop' 2024-10-09 00:10:04 +02:00
Thibault Deckers
53b8e5b37b Merge branch 'develop' 2024-09-17 20:22:40 +02:00
Thibault Deckers
4688ba01f2 Merge branch 'develop' 2024-09-16 20:02:52 +02:00
Thibault Deckers
50416ad59a Merge branch 'develop' 2024-09-16 00:17:24 +02:00
Thibault Deckers
ce3afd87a0 Merge branch 'develop' 2024-09-16 00:14:08 +02:00
Thibault Deckers
5565ac08f7 Merge branch 'develop' 2024-09-01 01:32:32 +02:00
Thibault Deckers
00681cbaee Merge branch 'develop' 2024-08-07 22:01:05 +02:00
Thibault Deckers
9ea43d951c Merge branch 'develop' 2024-07-19 08:31:52 +02:00
Thibault Deckers
86b0d16ad1 Merge branch 'develop' 2024-07-18 23:59:02 +02:00
Thibault Deckers
d813a61b9b Merge branch 'develop' 2024-07-17 22:39:17 +02:00
Thibault Deckers
6d4c765613 Merge branch 'develop' 2024-07-11 19:03:40 +02:00
Thibault Deckers
a396635639 Merge branch 'develop' 2024-07-09 00:18:12 +02:00
Thibault Deckers
90fa60df69 Merge branch 'develop' 2024-06-17 21:40:55 +02:00
Thibault Deckers
ce11587482 Merge branch 'develop' 2024-06-11 23:11:21 +02:00
Thibault Deckers
07ac7b3fda Merge branch 'develop' 2024-05-03 00:43:58 +02:00
Thibault Deckers
645c199b33 Merge branch 'develop' 2024-05-01 18:03:08 +02:00
Thibault Deckers
ed75dba228 Merge branch 'develop' 2024-04-14 23:25:04 +02:00
Thibault Deckers
2619613ee5 Merge branch 'develop' 2024-04-02 00:01:32 +02:00
Thibault Deckers
82f070f8a1 Merge branch 'develop' 2024-04-01 23:10:42 +02:00
Thibault Deckers
42a869908c Merge branch 'develop' 2024-03-12 19:54:44 +01:00
Thibault Deckers
a02131c7c8 Merge branch 'develop' 2024-03-11 23:33:40 +01:00
Thibault Deckers
579e7a2db0 Merge branch 'develop' 2024-02-22 20:24:59 +01:00
Thibault Deckers
283a3eba60 Merge branch 'develop' 2024-02-07 22:30:36 +01:00
Thibault Deckers
6f7f70babe Merge branch 'develop' 2024-01-29 23:49:00 +01:00
Thibault Deckers
7cd170baf9 Merge branch 'develop' 2023-12-24 16:51:18 +01:00
Thibault Deckers
32fff626d2 Merge branch 'develop' 2023-12-21 18:57:11 +01:00
Thibault Deckers
5b6d7af0ac Merge branch 'develop' 2023-12-02 17:33:59 +01:00
Thibault Deckers
aa2b4c14e0 Merge branch 'develop' 2023-10-17 00:21:24 +02:00
Thibault Deckers
bf322c0aa9 Merge branch 'develop' 2023-09-25 00:38:11 +02:00
Thibault Deckers
1b87fb896d Merge branch 'develop' 2023-09-17 22:55:45 +02:00
Thibault Deckers
20f6d3f2a7 Merge branch 'develop' 2023-09-13 23:07:00 +02:00
Thibault Deckers
ffc6201e28 Merge branch 'develop' 2023-08-28 23:05:34 +02:00
Thibault Deckers
c4e06113b4 Merge branch 'develop' 2023-08-24 19:59:21 +02:00
Thibault Deckers
271809e189 Merge branch 'develop' 2023-08-22 23:43:28 +02:00
Thibault Deckers
fb8a97c5c6 Merge branch 'develop' 2023-08-21 00:20:29 +02:00
Thibault Deckers
cf64527c4b Merge branch 'develop' 2023-06-04 23:04:22 +02:00
Thibault Deckers
055d341a84 Merge branch 'develop' 2023-05-28 13:39:12 +02:00
Thibault Deckers
dc34fc6bc2 Merge branch 'develop' 2023-05-28 12:51:03 +02:00
Thibault Deckers
fa16430063 Merge branch 'develop' 2023-05-27 00:45:35 +02:00
Thibault Deckers
58e7adecde Merge branch 'develop' 2023-05-26 23:45:09 +02:00
Thibault Deckers
b8e8e3bfba Merge branch 'develop' 2023-05-26 23:16:42 +02:00
Thibault Deckers
79c5f5777b Merge branch 'develop' 2023-05-26 23:08:12 +02:00
745 changed files with 4591 additions and 13084 deletions

View file

@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
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
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0

View file

@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: audit
@ -28,9 +28,6 @@ jobs:
- name: Get Flutter packages
run: ./flutterw pub get
- name: Generate app localizations
run: ./flutterw gen-l10n
- name: Static analysis.
run: ./flutterw analyze
@ -55,14 +52,14 @@ jobs:
build-mode: manual
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
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
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
with:
distribution: 'temurin'
java-version: '21'
@ -72,7 +69,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@ -86,6 +83,6 @@ jobs:
./flutterw build apk --profile -t lib/main_play.dart --flavor play
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
category: "/language:${{matrix.language}}"

View file

@ -18,14 +18,14 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
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
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
with:
distribution: 'temurin'
java-version: '21'
@ -36,9 +36,6 @@ jobs:
- name: Get Flutter packages
run: ./flutterw pub get
- name: Generate app localizations
run: ./flutterw gen-l10n
- name: Update Flutter version file
run: scripts/update_flutter_version.sh
@ -78,19 +75,19 @@ jobs:
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
with:
subject-path: 'outputs/*'
- name: Create GitHub release
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with:
artifacts: "outputs/*"
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload app bundle
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: appbundle
path: outputs/app-play-release.aab
@ -101,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: audit
@ -109,7 +106,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get appbundle from artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: appbundle

View file

@ -31,7 +31,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: audit
@ -41,7 +41,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
with:
results_file: results.sarif
results_format: sarif
@ -63,7 +63,7 @@ jobs:
# 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
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: SARIF file
path: results.sarif
@ -71,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: results.sarif

6
.gitignore vendored
View file

@ -5,11 +5,9 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
@ -29,6 +27,7 @@ migrate_working_dir/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
@ -47,6 +46,3 @@ app.*.map.json
# screenshot generation
/test_driver/assets/screenshots/
/screenshots/
# generated files
/lib/l10ngen/app_localizations*

29
.vscode/launch.json vendored
View file

@ -1,29 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "aves (main play)",
"request": "launch",
"type": "dart",
"program": "lib/main_play.dart",
"args": [
"--flavor",
"play"
]
},
{
"name": "aves (main play) [profile]",
"request": "launch",
"type": "dart",
"program": "lib/main_play.dart",
"args": [
"--flavor",
"play"
],
"flutterMode": "profile"
}
]
}

View file

@ -4,125 +4,6 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- Info: show matching dynamic albums
### Fixed
- crash when decoding some large thumbnails
## <a id="v1.13.2"></a>[v1.13.2] - 2025-06-02
### Changed
- downgraded Flutter to stable v3.27.4
- prevent display orientation flip when device rotation is locked
### Fixed
- moved file losing its extension and no longer being detected as media in some cases
- opening home when launching app as media picker
- removing groups with obsolete albums
- loading group custom covers
- crash when parsing some large media with trailing thumbnail
## <a id="v1.13.1"></a>[v1.13.1] - 2025-05-14
### Fixed
- albums: show groups to move/copy/export items
- albums: hide grouped albums containing hidden items only
## <a id="v1.13.0"></a>[v1.13.0] - 2025-05-12
### Added
- Albums: groups
- Collection: sort by storage path
- Search: week day filters
### Changed
- revert to Skia rendering engine
## <a id="v1.12.10"></a>[v1.12.10] - 2025-04-16
### Added
- Search: format filters
- Albums: sort by path
### Changed
- upgraded Flutter to stable v3.29.3
### Fixed
- region decoding failing to access decoder pool
## <a id="v1.12.9"></a>[v1.12.9] - 2025-04-06
### Added
- Kannada translation (thanks Chethan, Prasannakumar T Bhat)
### Changed
- enable Impeller rendering engine
### Fixed
- memory pressure during browsing
## <a id="v1.12.8"></a>[v1.12.8] - 2025-03-25
### Fixed
- swiping images for some combinations of screen size, device pixel ratio, and image size
## <a id="v1.12.7"></a>[v1.12.7] - 2025-03-16
### Added
- handle launch error to report and export DB
### Changed
- DB post-upgrade sanitization
- upgraded Flutter to stable v3.29.2
## <a id="v1.12.6"></a>[v1.12.6] - 2025-03-11
### Fixed
- data loss when editing metadata of items with incorrect mime types
- metadata inconsistency in the DB due to v1.12.4 upgrade
## <a id="v1.12.5"></a>[v1.12.5] - 2025-03-07
### Added
- support for Samsung HEIC motion photos embedding video in sefd box
- Cataloguing: identify video location from Apple QuickTime metadata, and 3GPP `loci` atom
- Collection: stack RAW and HEIC with same file names
- display home tile in side drawer when customized
- Galician translation (thanks Rubén Castiñeiras Lorenzo)
### Changed
- increased precision of file modified date to milliseconds
- upgraded Flutter to stable v3.29.1
### Fixed
- opening motion photo embedded video when video track is not the first one
- some SVG rendering issues
- decoding of SVG containing references to namespaces in !ATTLIST
- fallback decoding of images packed in RGBA_1010102 config
## <a id="v1.12.4"></a>[v1.12.4] - 2025-03-05 [YANKED]
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
### Added

View file

@ -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.
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
@ -111,96 +111,17 @@ Some users have expressed the wish to financially support the project. Thanks!
## Project Setup
### Install dependencies
Before running or building the app, update the dependencies for the desired flavor:
```
scripts/apply_flavor_play.sh
# scripts/apply_flavor_play.sh
```
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
### To run the app:
To run the app:
```
./flutterw run -t lib/main_play.dart --flavor play
```
### To build the app:
creare file con le tue credenziali file.keystore
dove YOUR_ALIAS_NAME è il tuo unico alias name
e YOUR_ALIAS_PWD è la password del tuo alias
```sh
keytool -genkey -v -keystore file.keystore -alias YOUR_ALIAS_NAME -storepass YOUR_ALIAS_PWD -keypass YOUR_ALIAS_PWD -keyalg RSA -validity 36500
```
in questo caso ho inserito
```sh
cd android
keytool -genkey -v -keystore file.keystore -alias FabioMich66 -storepass Master66 -keypass Master66 -keyalg RSA -validity 36500
```
se non puoi eseguire keytool perchè non è nel path di sistema cercalo usando
```sh
cd /
sudo find -name keytool
```
compilare il file `<app dir>/android/key.properties`
```
nano android/key.properties
```
questi i miei dati utilizzando il format key_template.properties
```
storeFile=/Users/fabio/flutter_apps/aves/android/file.keystore
storePassword=Master66
keyAlias=FabioMich66
keyPassword=Master66
googleApiKey=<GOOGLE_API_KEY>
```
infine compilare l'apk
```
./flutterw build apk -t lib/main_play.dart --flavor play
# ./flutterw run -t lib/main_play.dart --flavor play
```
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/actions/workflow/status/deckerst/aves/quality-check.yml?branch=develop
## Android studio
caricare il file da github selezionando le mnù a tendina File-New-project from Version Control
selezionare version control tipo: git
inserire URL di aves
https://github.com/deckerst/aves
flaggare shallow clone with history troncated 1 commits
aprire la console sulla dir aves appena creata e caricare le dipendenze
```
scripts/apply_flavor_izzy.sh
```
in settings - Languages and Framework - Dart inserire il path
```
/home/fabio/flutter/bin/cache/
```
e spuntare project aves
Edit configurations e aggiungere shell script con un nome x es izzi
poi flaggare script text e inserire
./flutterw run -t lib/main_izzy.dart --flavor izzy
la working directory sarà una cosa così
/home/fabio/StudioProjects/aves

View file

@ -9,11 +9,6 @@ analyzer:
# implicit-casts: false
# implicit-dynamic: false
# cf https://github.com/dart-lang/dart_style/wiki/Configuration
formatter:
page_width: 240
trailing_commas: preserve
linter:
rules:
# from 'flutter_lints', excluded

5
android/.gitignore vendored
View file

@ -5,12 +5,9 @@ gradle-wrapper.jar
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
.kotlin/
/build/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

View file

@ -33,13 +33,15 @@ kotlin {
}
android {
namespace = 'deckers.thibault.aves'
compileSdk = 36
namespace 'deckers.thibault.aves'
compileSdk 35
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
ndkVersion '28.0.12916984'
defaultConfig {
applicationId packageName
minSdk flutter.minSdkVersion
targetSdk 36
targetSdk 35
versionCode flutter.versionCode
versionName flutter.versionName
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>"]
@ -134,14 +136,14 @@ flutter {
repositories {
maven {
url = 'https://jitpack.io'
url 'https://jitpack.io'
content {
includeGroup "com.github.deckerst"
includeGroup "com.github.deckerst.mp4parser"
}
}
maven {
url = 'https://s3.amazonaws.com/repo.commonsware.com'
url 'https://s3.amazonaws.com/repo.commonsware.com'
content {
excludeGroupByRegex "com\\.github\\.deckerst.*"
}
@ -149,36 +151,35 @@ repositories {
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
implementation "androidx.appcompat:appcompat:1.7.1"
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.lifecycle:lifecycle-process:2.9.1'
implementation "androidx.appcompat:appcompat:1.7.0"
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
implementation 'androidx.media:media:1.7.0'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.security:security-crypto:1.1.0-beta01'
implementation 'androidx.work:work-runtime-ktx:2.10.1'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.work:work-runtime-ktx:2.10.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0'
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
implementation "com.github.bumptech.glide:glide:$glide_version"
implementation 'com.google.android.material:material:1.12.0'
// SLF4J implementation for `mp4parser`
implementation 'org.slf4j:slf4j-simple:2.0.17'
implementation 'org.slf4j:slf4j-simple:2.0.16'
// forked, built by JitPack:
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
// - https://jitpack.io/p/deckerst/androidsvg
// - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:d6b2b0aa4f'
implementation 'com.github.deckerst:androidsvg:67db933051'
implementation 'com.github.deckerst.mp4parser:isoparser:c2898f1832'
implementation 'com.github.deckerst.mp4parser:muxer:c2898f1832'
implementation 'com.github.deckerst:pixymeta-android:cb1cdc932e'
implementation 'com.github.deckerst:Android-TiffBitmapFactory:3ed067f021'
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
implementation 'com.github.deckerst:pixymeta-android:71eee77dc4'
implementation project(':exifinterface')
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.13.1'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.4'
kapt 'androidx.annotation:annotation:1.9.1'
ksp "com.github.bumptech.glide:ksp:$glide_version"

View file

@ -329,6 +329,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!--
Screenshot driver scenario is not supported by Impeller:
"Compressed screenshots not supported for Impeller"
-->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />

View file

@ -14,7 +14,6 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
@ -45,12 +44,11 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
private var backgroundChannel: MethodChannel? = null
override suspend fun doWork(): Result {
Log.i(LOG_TAG, "Start analysis worker $id")
defaultScope.launch {
// prevent ANR triggered by slow operations in main thread
createNotificationChannel()
setForeground(createForegroundInfo())
}.join()
}
suspendCoroutine { cont ->
workCont = cont
onStart()
@ -70,6 +68,7 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
}
private fun onStart() {
Log.i(LOG_TAG, "Start analysis worker $id")
runBlocking {
FlutterUtils.initFlutterEngine(applicationContext, SHARED_PREFERENCES_KEY, PREF_CALLBACK_HANDLE_KEY) {
flutterEngine = it
@ -133,7 +132,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
result.success(null)
}
"updateNotification" -> defaultScope.launch { safeSuspend(call, result, ::updateNotification) }
"updateNotification" -> {
val title = call.argument<String>("title")
val message = call.argument<String>("message")
setForegroundAsync(createForegroundInfo(title, message))
result.success(null)
}
"stop" -> {
workCont?.resume(null)
@ -176,22 +180,17 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
.setContentIntent(openAppIntent)
.addAction(stopAction)
.build()
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
// https://developer.android.com/guide/background/persistent/how-to/long-running
return when {
Build.VERSION.SDK_INT >= 35 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING)
Build.VERSION.SDK_INT == 34 -> ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else -> ForegroundInfo(NOTIFICATION_ID, notification)
return if (Build.VERSION.SDK_INT == 34) {
// from Android 14 (API 34), foreground service type is mandatory for long-running workers:
// https://developer.android.com/guide/background/persistent/how-to/long-running
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else if (Build.VERSION.SDK_INT >= 35) {
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 {
private val LOG_TAG = LogUtils.createTag<AnalysisWorker>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"

View file

@ -15,7 +15,6 @@ import android.os.Looper
import android.util.Log
import android.util.SizeF
import android.widget.RemoteViews
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
@ -219,7 +218,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
val heightPx = (sizeDip.height * devicePixelRatio).roundToInt()
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)
it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}

View file

@ -19,7 +19,6 @@ import androidx.annotation.RequiresApi
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
import deckers.thibault.aves.channel.calls.AccessibilityHandler
@ -70,6 +69,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import androidx.core.net.toUri
// `FlutterFragmentActivity` because of local auth plugin
open class MainActivity : FlutterFragmentActivity() {

View file

@ -2,12 +2,12 @@ package deckers.thibault.aves
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.AppAdapterHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class WallpaperActivity : MainActivity() {
private var originalIntent: String? = null

View file

@ -21,28 +21,27 @@ class AvesByteSendingMethodCodec private constructor() : MethodCodec {
return STANDARD.encodeMethodCall(methodCall)
}
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
return STANDARD.encodeErrorEnvelope(errorCode, errorMessage, errorDetails)
}
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
return STANDARD.encodeErrorEnvelopeWithStacktrace(errorCode, errorMessage, errorDetails, errorStacktrace)
}
// `StandardMethodCodec` writes the result to a `ByteArrayOutputStream`, then writes the stream to a `ByteBuffer`.
// Here we only handle `ByteArray` results, but we avoid the intermediate stream.
override fun encodeSuccessEnvelope(result: Any?): ByteBuffer {
if (result is ByteArray) {
return ByteBuffer.allocateDirect(1 + result.size).apply {
// following `StandardMethodCodec`:
// First byte is zero in success case, and non-zero otherwise.
val size = result.size
return ByteBuffer.allocateDirect(4 + size).apply {
put(0)
put(result)
}
}
Log.e(LOG_TAG, "encodeSuccessEnvelope failed with result=$result")
return encodeErrorEnvelope("invalid-result-type", "Called success with a result which is not a `ByteArray`, type=${result?.javaClass}", null)
return ByteBuffer.allocateDirect(0)
}
override fun encodeErrorEnvelope(errorCode: String, errorMessage: String?, errorDetails: Any?): ByteBuffer {
Log.e(LOG_TAG, "encodeErrorEnvelope failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails")
return ByteBuffer.allocateDirect(0)
}
override fun encodeErrorEnvelopeWithStacktrace(errorCode: String, errorMessage: String?, errorDetails: Any?, errorStacktrace: String?): ByteBuffer {
Log.e(LOG_TAG, "encodeErrorEnvelopeWithStacktrace failed with errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, errorStacktrace=$errorStacktrace")
return ByteBuffer.allocateDirect(0)
}
companion object {

View file

@ -2,7 +2,6 @@ package deckers.thibault.aves.channel.calls
import android.app.ActivityManager
import android.content.Context
import androidx.core.content.edit
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
@ -19,6 +18,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class AnalysisHandler(private val activity: FlutterFragmentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -38,8 +38,9 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
}
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
with(preferences.edit()) {
putLong(AnalysisWorker.PREF_CALLBACK_HANDLE_KEY, callbackHandle)
apply()
}
result.success(true)
}
@ -68,8 +69,9 @@ class AnalysisHandler(private val activity: FlutterFragmentActivity, private val
// work `Data` cannot occupy more than 10240 bytes when serialized
// so we save the possibly long list of entry IDs to shared preferences
val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
with(preferences.edit()) {
putStringSet(AnalysisWorker.PREF_ENTRY_IDS_KEY, allEntryIds?.map { it.toString() }?.toSet())
apply()
}
val workData = workDataOf(

View file

@ -38,6 +38,7 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.anyCauseIs
import deckers.thibault.aves.utils.getApplicationInfoCompat
@ -153,7 +154,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
val density = context.resources.displayMetrics.density
val size = (sizeDip * density).roundToInt()
var bytes: ByteArray? = null
var data: ByteArray? = null
try {
val iconResourceId = context.packageManager.getApplicationInfoCompat(packageName, 0).icon
if (iconResourceId != Resources.ID_NULL) {
@ -174,9 +175,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
try {
val bitmap = withContext(Dispatchers.IO) { target.get() }
// do not recycle bitmaps fetched from `ContentResolver` as their lifecycle is unknown
val recycle = false
bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
data = bitmap?.getBytes(canHaveAlpha = true, recycle = false)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}
@ -186,8 +185,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return
}
if (bytes != null) {
result.success(bytes)
if (data != null) {
result.success(data)
} else {
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
}

View file

@ -316,7 +316,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
val sb = StringBuilder()
if (mimeType == MimeTypes.MP4 || MimeTypes.isHeic(mimeType)) {
if (mimeType == MimeTypes.MP4) {
try {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.LocaleConfig
import android.app.LocaleManager
import android.content.Context
@ -103,7 +102,6 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
}
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(",")))
}

View file

@ -10,6 +10,7 @@ import com.adobe.internal.xmp.XMPUtils
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.metadataextractor.Helper
@ -22,6 +23,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.FileUtils.transferFrom
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
@ -45,7 +47,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getExifThumbnails" -> ioScope.launch { safe(call, result, ::getExifThumbnails) }
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
"extractGoogleDeviceItem" -> ioScope.launch { safe(call, result, ::extractGoogleDeviceItem) }
"extractJpegMpfItem" -> ioScope.launch { safe(call, result, ::extractJpegMpfItem) }
"extractMotionPhotoImage" -> ioScope.launch { safe(call, result, ::extractMotionPhotoImage) }
@ -56,7 +58,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
@ -73,9 +75,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { bitmap ->
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
// do not recycle bitmaps fetched from `ExifInterface` as their lifecycle is unknown
val recycle = false
BitmapUtils.getRawBytes(it, recycle = recycle)?.let { bytes -> thumbnails.add(bytes) }
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
}
}
}
@ -186,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
MultiPage.getTrailerVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val imageSizeBytes = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
@ -207,10 +207,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
MultiPage.getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoOffset)
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSize)
input.skip(videoStartOffset)
copyEmbeddedBytes(result, MimeTypes.MP4, displayName, input, videoSizeBytes)
}
return
}
@ -311,7 +312,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
embeddedByteStream: InputStream,
embeddedByteLength: Long,
) {
val extension = extensionFor(mimeType, defaultExtension = null)
val extension = extensionFor(mimeType)
val targetFile = StorageUtils.createTempFile(context, extension).apply {
transferFrom(embeddedByteStream, embeddedByteLength)
}
@ -319,7 +320,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
val authority = "${context.applicationContext.packageName}.file_provider"
val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content
val displayNameWithExtension = if (displayName.endsWith(extension, ignoreCase = true)) {
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
displayName
} else {
"$displayName$extension"

View file

@ -31,7 +31,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
private fun getAddress(call: MethodCall, result: MethodChannel.Result) {
val latitude = call.argument<Number>("latitude")?.toDouble()
val longitude = call.argument<Number>("longitude")?.toDouble()
val localeLanguageTag = call.argument<String>("localeLanguageTag")
val localeString = call.argument<String>("locale")
val maxResults = call.argument<Int>("maxResults") ?: 1
if (latitude == null || longitude == null) {
result.error("getAddress-args", "missing arguments", null)
@ -43,8 +43,11 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
return
}
geocoder = geocoder ?: if (localeLanguageTag != null) {
Geocoder(context, Locale.forLanguageTag(localeLanguageTag))
geocoder = geocoder ?: if (localeString != null) {
val split = localeString.split("_")
val language = split[0]
val country = if (split.size > 1) split[1] else ""
Geocoder(context, Locale(language, country))
} else {
Geocoder(context)
}

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import androidx.core.content.edit
import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
@ -30,8 +29,9 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
}
val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
preferences.edit {
with(preferences.edit()) {
putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle)
apply()
}
result.success(true)
}

View file

@ -3,12 +3,11 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
import android.graphics.Rect
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.utils.MimeTypes
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@ -28,18 +27,18 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getThumbnail" -> ioScope.launch { safe(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safe(call, result, ::getRegion) }
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
else -> result.notImplemented()
}
}
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>(EntryFields.URI)
val mimeType = call.argument<String>(EntryFields.MIME_TYPE)
val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong()
val rotationDegrees = call.argument<Int>(EntryFields.ROTATION_DEGREES)
val isFlipped = call.argument<Boolean>(EntryFields.IS_FLIPPED)
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")
val mimeType = call.argument<String>("mimeType")
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
val rotationDegrees = call.argument<Int>("rotationDegrees")
val isFlipped = call.argument<Boolean>("isFlipped")
val widthDip = call.argument<Number>("widthDip")?.toDouble()
val heightDip = call.argument<Number>("heightDip")?.toDouble()
val pageId = call.argument<Int>("pageId")
@ -56,7 +55,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
context = context,
uri = uri,
mimeType = mimeType,
dateModifiedMillis = dateModifiedMillis ?: (Date().time),
dateModifiedSecs = dateModifiedSecs ?: (Date().time / 1000),
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
width = (widthDip * density).roundToInt(),
@ -68,7 +67,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
).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 mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId")
@ -97,7 +96,6 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
imageHeight = imageHeight,
result = result,
)
MimeTypes.TIFF -> TiffRegionFetcher(context).fetch(
uri = uri,
page = pageId ?: 0,
@ -105,7 +103,6 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
regionRect = regionRect,
result = result,
)
else -> regionFetcher.fetch(
uri = uri,
mimeType = mimeType,

View file

@ -1,8 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
@ -23,8 +21,7 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
"clearImageDiskCache" -> ioScope.launch { safe(call, result, ::clearImageDiskCache) }
"clearImageMemoryCache" -> ioScope.launch { safe(call, result, ::clearImageMemoryCache) }
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
}
}
@ -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()
result.success(null)
}
private fun clearImageMemoryCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
Handler(Looper.getMainLooper()).post {
Glide.get(context).clearMemory()
}
result.success(null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/media_fetch_object"
}

View file

@ -6,7 +6,6 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.XMPMetaFactory
@ -23,7 +22,6 @@ import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory
@ -102,8 +100,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
import org.mp4parser.tools.Path
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import java.text.ParseException
@ -111,6 +107,7 @@ import java.util.Locale
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -452,8 +449,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (isVideo(mimeType)) {
// `metadata-extractor` do not extract custom tags in user data box
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { box ->
metadataMap[Metadata.DIR_MP4_USER_DATA] = Mp4ParserHelper.extractBoxFields(box)
val userDataDir = Mp4ParserHelper.getUserData(context, mimeType, uri)
if (userDataDir.isNotEmpty()) {
metadataMap[Metadata.DIR_MP4_USER_DATA] = userDataDir
}
// this is used as fallback when the video metadata cannot be found on the Dart side
@ -472,12 +470,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
// about embedded images as they do not list them as separate tracks
// and only identify at most one
} else if (isHeic(mimeType)) {
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (_, bytes) ->
metadataMap[Mp4ParserHelper.SAMSUNG_MAKERNOTE_BOX_TYPE] = hashMapOf(
"Size" to bytes.size.toString(),
)
}
}
if (metadataMap.isNotEmpty()) {
@ -535,33 +527,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val metadataMap = HashMap<String, Any>()
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
if (isVideo(mimeType) || isHeic(mimeType)) {
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
// fallback to MP4 `loci` box for location
if (!metadataMap.contains(KEY_LATITUDE) || !metadataMap.contains(KEY_LONGITUDE)) {
try {
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { userDataBox ->
Path.getPath<LocationInformationBox>(userDataBox, LocationInformationBox.TYPE)?.let { locationBox ->
if (!locationBox.isParsed) {
locationBox.parseDetails()
}
metadataMap[KEY_LATITUDE] = locationBox.latitude
metadataMap[KEY_LONGITUDE] = locationBox.longitude
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get Location Information box by MP4 parser for mimeType=$mimeType uri=$uri", e)
}
}
}
if (isHeic(mimeType)) {
val flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
if ((flags and MASK_IS_MOTION_PHOTO == 0) && MultiPage.isHeicSefdMotionPhoto(context, uri)) {
metadataMap[KEY_FLAGS] = flags or MASK_IS_MULTIPAGE or MASK_IS_MOTION_PHOTO
}
}
// report success even when empty
@ -719,22 +686,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
}
}
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
for (dir in metadata.getDirectoriesOfType(QuickTimeMetadataDirectory::class.java)) {
dir.getSafeString(QuickTimeMetadataDirectory.TAG_LOCATION_ISO6709) { locationString ->
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) {
val latitude = matcher.group(1)?.toDoubleOrNull()
val longitude = matcher.group(2)?.toDoubleOrNull()
if (latitude != null && longitude != null) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
}
}
}
}
when (mimeType) {
MimeTypes.PNG -> {
// date fallback to PNG time chunk
@ -879,7 +830,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
}
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
if (!metadataMap.containsKey(KEY_LATITUDE)) {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
@ -1007,7 +958,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
// returns description from these fields (by precedence):
// return description from these fields (by precedence):
// - XMP / dc:description
// - IPTC / caption-abstract
// - Exif / UserComment
@ -1192,8 +1143,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(null)
}
// returns XMP components
// returns an empty list if there is no XMP
// return XMP components
// return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.toUri()

View file

@ -2,7 +2,6 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
@ -46,7 +45,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
}
val preferences = getStore()
preferences.edit {
with(preferences.edit()) {
when (value) {
is Boolean -> putBoolean(key, value)
is Float -> putFloat(key, value)
@ -59,6 +58,7 @@ class SecurityHandler(private val context: Context) : MethodCallHandler {
return
}
}
apply()
}
result.success(true)
}

View file

@ -4,24 +4,21 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ColorSpace
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.util.Log
import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage
import 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.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MathUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.max
import kotlin.math.roundToInt
@ -31,10 +28,11 @@ import kotlin.math.roundToInt
class RegionFetcher internal constructor(
private val context: Context,
) {
// returns decoded bytes in ARGB_8888, with trailer bytes:
// - width (int32)
// - height (int32)
fun fetch(
private var lastDecoderRef: LastDecoderRef? = null
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
suspend fun fetch(
uri: Uri,
mimeType: String,
pageId: Int?,
@ -61,12 +59,24 @@ class RegionFetcher internal constructor(
return
}
var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) {
currentDecoderRef = null
}
try {
val decoder = getOrCreateDecoder(context, uri, requestKey)
if (decoder == null) {
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoderCompat.newInstance(input)
}
if (newDecoder == null) {
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return
}
currentDecoderRef = LastDecoderRef(requestKey, newDecoder)
}
val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef
// with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly
@ -88,38 +98,20 @@ class RegionFetcher internal constructor(
}
}
val options = BitmapFactory.Options().apply {
inSampleSize = effectiveSampleSize
// Specifying preferred config and color space avoids the need for conversion afterwards,
// but may prevent decoding (e.g. from RGBA_1010102 to ARGB_8888 on some devices).
inPreferredConfig = PREFERRED_CONFIG
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
}
}
val pixelCount = effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), options.inPreferredConfig)
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * effectiveRect.width() * effectiveRect.height() / effectiveSampleSize
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
// decoding a region that large would yield an OOM when creating the bitmap
result.error("fetch-large-region", "Region too large for uri=$uri regionRect=$regionRect", null)
return
}
var bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap == null) {
// retry without specifying config or color space,
// falling back to custom byte conversion afterwards
options.inPreferredConfig = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && options.inPreferredColorSpace != null) {
options.inPreferredColorSpace = null
}
bitmap = decoder.decodeRegion(effectiveRect, options)
val options = BitmapFactory.Options().apply {
inSampleSize = effectiveSampleSize
}
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
if (bytes != null) {
result.success(bytes)
val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
} else {
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
@ -166,40 +158,12 @@ class RegionFetcher internal constructor(
}
}
private data class DecoderRef(
private data class LastDecoderRef(
val requestKey: Pair<Uri, Int?>,
val decoder: BitmapRegionDecoder,
)
companion object {
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
private const val DECODER_POOL_SIZE = 3
private val decoderPool = ArrayList<DecoderRef>()
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
private val poolLock = ReentrantLock()
private fun getOrCreateDecoder(context: Context, uri: Uri, requestKey: Pair<Uri, Int?>): BitmapRegionDecoder? {
poolLock.withLock {
var decoderRef = decoderPool.firstOrNull { it.requestKey == requestKey }
if (decoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoderCompat.newInstance(input)
}
if (newDecoder == null) {
return null
}
decoderRef = DecoderRef(requestKey, newDecoder)
} else {
decoderPool.remove(decoderRef)
}
decoderPool.add(0, decoderRef)
while (decoderPool.size > DECODER_POOL_SIZE) {
decoderPool.removeAt(decoderPool.size - 1)
}
return decoderRef.decoder
}
}
}
}

View file

@ -6,25 +6,25 @@ import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import androidx.core.graphics.createBitmap
import com.caverock.androidsvg.PreserveAspectRatio
import com.caverock.androidsvg.RenderOptions
import com.caverock.androidsvg.SVG
import com.caverock.androidsvg.SVGParseException
import deckers.thibault.aves.metadata.SVGParserBufferedInputStream
import deckers.thibault.aves.metadata.SvgHelper.normalizeSize
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.ceil
class SvgRegionFetcher internal constructor(
private val context: Context,
) {
fun fetch(
private var lastSvgRef: LastSvgRef? = null
suspend fun fetch(
uri: Uri,
sizeBytes: Long?,
scale: Int,
@ -39,12 +39,32 @@ class SvgRegionFetcher internal constructor(
return
}
var currentSvgRef = lastSvgRef
if (currentSvgRef != null && currentSvgRef.uri != uri) {
currentSvgRef = null
}
try {
val svg = getOrCreateDecoder(context, uri)
if (svg == null) {
result.error("fetch-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
return
if (currentSvgRef == null) {
val newSvg = StorageUtils.openInputStream(context, uri)?.use { input ->
try {
SVG.getFromInputStream(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
val viewBox = svg.documentViewBox
@ -71,65 +91,32 @@ class SvgRegionFetcher internal constructor(
val targetBitmapWidth = regionRect.width()
val targetBitmapHeight = regionRect.height()
val canvasWidth = targetBitmapWidth + bleedX * 2
val canvasHeight = targetBitmapHeight + bleedY * 2
val config = PREFERRED_CONFIG
val pixelCount = canvasWidth * canvasHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), config)
// use `Long` as rect size could be unexpectedly large and go beyond `Int` max
val targetBitmapSizeBytes: Long = ARGB_8888_BYTE_SIZE.toLong() * targetBitmapWidth * targetBitmapHeight
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
// decoding a region that large would yield an OOM when creating the bitmap
result.error("fetch-read-large-region", "SVG region too large for uri=$uri regionRect=$regionRect", null)
return
}
var bitmap = createBitmap(canvasWidth, canvasHeight, config)
var bitmap = Bitmap.createBitmap(
targetBitmapWidth + bleedX * 2,
targetBitmapHeight + bleedY * 2,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
svg.renderToCanvas(canvas, renderOptions)
bitmap = Bitmap.createBitmap(bitmap, bleedX, bleedY, targetBitmapWidth, targetBitmapHeight)
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
result.success(bytes)
} catch (e: SVGParseException) {
result.error("fetch-parse", "failed to parse SVG for uri=$uri regionRect=$regionRect", null)
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} catch (e: Exception) {
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
private data class DecoderRef(
private data class LastSvgRef(
val uri: Uri,
val decoder: SVG,
val svg: SVG,
)
companion object {
private val PREFERRED_CONFIG = Bitmap.Config.ARGB_8888
private const val DECODER_POOL_SIZE = 3
private val decoderPool = ArrayList<DecoderRef>()
private val poolLock = ReentrantLock()
private fun getOrCreateDecoder(context: Context, uri: Uri): SVG? {
poolLock.withLock {
var decoderRef = decoderPool.firstOrNull { it.uri == uri }
if (decoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
SVG.getFromInputStream(SVGParserBufferedInputStream(input))
}
if (newDecoder == null) {
return null
}
newDecoder.normalizeSize()
decoderRef = DecoderRef(uri, newDecoder)
} else {
decoderPool.remove(decoderRef)
}
decoderPool.add(0, decoderRef)
while (decoderPool.size > DECODER_POOL_SIZE) {
decoderPool.removeAt(decoderPool.size - 1)
}
return decoderRef.decoder
}
}
}
}

View file

@ -5,11 +5,8 @@ import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.core.graphics.scale
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -17,9 +14,8 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.SVG
import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -28,14 +24,13 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel
import kotlin.math.min
import kotlin.math.roundToInt
import androidx.core.net.toUri
class ThumbnailFetcher internal constructor(
private val context: Context,
uri: String,
private val mimeType: String,
private val dateModifiedMillis: Long,
private val dateModifiedSecs: Long,
private val rotationDegrees: Int,
private val isFlipped: Boolean,
width: Int?,
@ -53,7 +48,7 @@ class ThumbnailFetcher internal constructor(
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
private val customFetch = svgFetch || tiffFetch || multiPageFetch
fun fetch() {
suspend fun fetch() {
var bitmap: Bitmap? = null
var exception: Exception? = null
@ -83,33 +78,7 @@ class ThumbnailFetcher internal constructor(
}
if (bitmap != null) {
if (bitmap.width > width && bitmap.height > height) {
val scalingFactor: Double = min(bitmap.width.toDouble() / width, bitmap.height.toDouble() / height)
val dstWidth = (bitmap.width / scalingFactor).roundToInt()
val dstHeight = (bitmap.height / scalingFactor).roundToInt()
Log.d(
LOG_TAG, "rescale thumbnail for mimeType=$mimeType uri=$uri width=$width height=$height" +
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height}" +
", to target=${dstWidth}x${dstHeight}"
)
bitmap = bitmap.scale(dstWidth, dstHeight)
}
if (bitmap.byteCount > BITMAP_SIZE_DANGER_THRESHOLD) {
result.error(
"getThumbnail-large", "thumbnail bitmap dangerously large" +
" for mimeType=$mimeType uri=$uri pageId=$pageId width=$width height=$height" +
", with bitmap byteCount=${bitmap.byteCount} size=${bitmap.width}x${bitmap.height} config=${bitmap.config?.name}", null
)
return
}
}
// do not recycle bitmaps fetched from `ContentResolver` or Glide as their lifecycle is unknown
val recycle = false
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = recycle)
if (bytes != null) {
result.success(bytes)
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality))
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
@ -150,7 +119,7 @@ class ThumbnailFetcher internal constructor(
// add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions()
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedMillis-$rotationDegrees-$isFlipped-$width-$pageId"))
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height)
if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
@ -172,9 +141,4 @@ class ThumbnailFetcher internal constructor(
Glide.with(context).clear(target)
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<ThumbnailFetcher>()
private const val BITMAP_SIZE_DANGER_THRESHOLD = 20 * (1 shl 20) // MB
}
}

View file

@ -1,10 +1,9 @@
package deckers.thibault.aves.channel.calls.fetchers
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.net.Uri
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import io.flutter.plugin.common.MethodChannel
import org.beyka.tiffbitmapfactory.DecodeArea
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@ -12,7 +11,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class TiffRegionFetcher internal constructor(
private val context: Context,
) {
fun fetch(
suspend fun fetch(
uri: Uri,
page: Int,
sampleSize: Int,
@ -32,10 +31,9 @@ class TiffRegionFetcher internal constructor(
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
val bytes = BitmapUtils.getRawBytes(bitmap, recycle = true)
if (bytes != null) {
result.success(bytes)
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
}

View file

@ -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) {
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) {
val wideColorGamut = call.argument<Boolean>("wideColorGamut")
val hdr = call.argument<Boolean>("hdr")
if (wideColorGamut == null || hdr == null) {
result.error("setColorMode-args", "missing arguments", null)
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
val on = call.argument<Boolean>("on")
if (on == null) {
result.error("setHdrColorMode-args", "missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.window.colorMode = if (hdr) {
ActivityInfo.COLOR_MODE_HDR
} else if (wideColorGamut) {
ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
} else {
ActivityInfo.COLOR_MODE_DEFAULT
}
activity.window.colorMode = if (on) ActivityInfo.COLOR_MODE_HDR else ActivityInfo.COLOR_MODE_DEFAULT
}
result.success(null)
}

View file

@ -29,15 +29,11 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
result.success(HashMap<String, Any>())
}
override fun supportsWideGamut(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun supportsHdr(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
override fun setColorMode(call: MethodCall, result: MethodChannel.Result) {
override fun setHdrColorMode(call: MethodCall, result: MethodChannel.Result) {
result.success(null)
}
}

View file

@ -18,9 +18,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
"getCutoutInsets" -> Coresult.safe(call, result, ::getCutoutInsets)
"supportsWideGamut" -> Coresult.safe(call, result, ::supportsWideGamut)
"supportsHdr" -> Coresult.safe(call, result, ::supportsHdr)
"setColorMode" -> Coresult.safe(call, result, ::setColorMode)
"setHdrColorMode" -> Coresult.safe(call, result, ::setHdrColorMode)
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 supportsWideGamut(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 {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()

View file

@ -49,7 +49,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> ioScope.launch { createFile() }
"openFile" -> ioScope.launch { openFile() }
"copyFile" -> ioScope.launch { copyFile() }
"edit" -> edit()
"pickCollectionFilters" -> pickCollectionFilters()
else -> endOfStream()
@ -182,49 +181,6 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
safeStartActivityForStorageAccessResult(intent, MainActivity.OPEN_FILE_REQUEST, ::onGranted, ::onDenied)
}
private suspend fun copyFile() {
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val sourceUri = (args["sourceUri"] as String?)?.toUri()
if (name == null || mimeType == null || sourceUri == null) {
error("copyFile-args", "missing arguments", null)
return
}
fun onGranted(uri: Uri) {
ioScope.launch {
try {
StorageUtils.openInputStream(activity, sourceUri)?.use { input ->
// truncate is necessary when overwriting a longer file
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (input.read(buffer).also { len = it } != -1) {
output.write(buffer, 0, len)
}
}
}
success(true)
} catch (e: Exception) {
error("copyFile-write", "failed to copy file from sourceUri=$sourceUri to uri=$uri", e.message)
}
endOfStream()
}
}
fun onDenied() {
success(null)
endOfStream()
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, name)
}
safeStartActivityForStorageAccessResult(intent, MainActivity.CREATE_FILE_REQUEST, ::onGranted, ::onDenied)
}
private fun edit() {
val uri = args["uri"] as String?
val mimeType = args["mimeType"] as String? // optional

View file

@ -8,8 +8,8 @@ import android.util.Log
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.InputStream
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
@ -81,13 +80,11 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return
}
val decoded = arguments["decoded"] as Boolean
val mimeType = arguments["mimeType"] as String?
val uri = (arguments["uri"] as String?)?.toUri()
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
val isAnimated = arguments["isAnimated"] as Boolean
val pageId = arguments["pageId"] as Int?
if (mimeType == null || uri == null) {
@ -96,31 +93,19 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
return
}
if (canDecodeWithFlutter(mimeType, isAnimated) && !decoded) {
// to be decoded by Flutter
streamOriginalEncodedBytes(uri, mimeType, sizeBytes)
} else if (isVideo(mimeType)) {
streamVideoByGlide(
uri = uri,
mimeType = mimeType,
sizeBytes = sizeBytes,
decoded = decoded,
)
if (isVideo(mimeType)) {
streamVideoByGlide(uri, mimeType, sizeBytes)
} else if (!canDecodeWithFlutter(mimeType, pageId, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, pageId, mimeType, sizeBytes, rotationDegrees, isFlipped)
} else {
streamImageByGlide(
uri = uri,
pageId = pageId,
mimeType = mimeType,
sizeBytes = sizeBytes,
rotationDegrees = rotationDegrees,
isFlipped = isFlipped,
decoded = decoded,
)
// to be decoded by Flutter
streamImageAsIs(uri, mimeType, sizeBytes)
}
endOfStream()
}
private fun streamOriginalEncodedBytes(uri: Uri, mimeType: String, sizeBytes: Long?) {
private fun streamImageAsIs(uri: Uri, mimeType: String, sizeBytes: Long?) {
if (!MemoryUtils.canAllocate(sizeBytes)) {
error("streamImage-image-read-large", "original image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
return
@ -140,7 +125,6 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
sizeBytes: Long?,
rotationDegrees: Int,
isFlipped: Boolean,
decoded: Boolean,
) {
val target = Glide.with(context)
.asBitmap()
@ -153,16 +137,9 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
val recycle = false
val bytes = if (decoded) {
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
} else {
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = MimeTypes.canHaveAlpha(mimeType), recycle = recycle)
}
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)
if (MemoryUtils.canAllocate(sizeBytes)) {
streamBytes(ByteArrayInputStream(bytes))
success(bytes)
} else {
error("streamImage-image-decode-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
}
@ -176,7 +153,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
}
}
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?, decoded: Boolean) {
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
val target = Glide.with(context)
.asBitmap()
.apply(AvesAppGlideModule.uncachedFullImageOptions)
@ -185,16 +162,9 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
try {
val bitmap = withContext(Dispatchers.IO) { target.get() }
if (bitmap != null) {
// do not recycle bitmaps fetched from Glide as their lifecycle is unknown
val recycle = false
val bytes = if (decoded) {
BitmapUtils.getRawBytes(bitmap, recycle = recycle)
} else {
BitmapUtils.getEncodedBytes(bitmap, canHaveAlpha = false, recycle = recycle)
}
val bytes = bitmap.getBytes(canHaveAlpha = false, recycle = false)
if (MemoryUtils.canAllocate(sizeBytes)) {
streamBytes(ByteArrayInputStream(bytes))
success(bytes)
} else {
error("streamImage-video-large", "decoded image too large at $sizeBytes bytes, for mimeType=$mimeType uri=$uri", null)
}

View file

@ -31,15 +31,9 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
init {
Log.i(LOG_TAG, "start listening to Media Store")
try {
context.contentResolver.apply {
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
}
} catch (e: SecurityException) {
// Trying to register an observer may yield a security exception with this message:
// "Failed to find provider media for user 0; expected to find a valid ContentProvider for this authority"
Log.w(LOG_TAG, "failed to register content observer", e)
context.contentResolver.apply {
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
}
}

View file

@ -19,13 +19,12 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
// knownEntries: map of contentId -> dateModifiedMillis
private var knownEntries: Map<Long?, Long?>? = null
private var knownEntries: Map<Long?, Int?>? = null
private var directory: String? = null
init {
if (arguments is Map<*, *>) {
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to (it.value as Number?)?.toLong() }?.toMap()
knownEntries = (arguments["knownEntries"] as? Map<*, *>?)?.map { (it.key as Number?)?.toLong() to it.value as Int? }?.toMap()
directory = arguments["directory"] as String?
}
}

View file

@ -2,7 +2,6 @@ package deckers.thibault.aves.decoder
import android.content.Context
import android.net.Uri
import android.text.format.Formatter
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
@ -11,17 +10,9 @@ import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter
import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
import com.bumptech.glide.load.engine.cache.DiskCache
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.load.engine.cache.MemorySizeCalculator
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
@ -32,30 +23,6 @@ class AvesAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR)
// sizing
val memorySizeCalculator = MemorySizeCalculator.Builder(context).build()
builder.setMemorySizeCalculator(memorySizeCalculator)
val size: Int = memorySizeCalculator.bitmapPoolSize
if (size > 0) {
builder.setBitmapPool(LruBitmapPool(size.toLong()))
} else {
builder.setBitmapPool(BitmapPoolAdapter())
}
builder.setArrayPool(LruArrayPool(memorySizeCalculator.arrayPoolSizeInBytes))
builder.setMemoryCache(LruResourceCache(memorySizeCalculator.memoryCacheSize.toLong()))
val diskCacheSize = DiskCache.Factory.DEFAULT_DISK_CACHE_SIZE
val internalCacheDiskCacheFactory = InternalCacheDiskCacheFactory(context, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR, diskCacheSize.toLong())
builder.setDiskCache(internalCacheDiskCacheFactory)
fun toMb(bytes: Int) = Formatter.formatFileSize(context, bytes.toLong())
Log.d(
LOG_TAG, "Glide disk cache size=${toMb(diskCacheSize)}" +
", memory cache size=${toMb(memorySizeCalculator.memoryCacheSize)}" +
", bitmap pool size=${toMb(memorySizeCalculator.bitmapPoolSize)}" +
", array pool size=${toMb(memorySizeCalculator.arrayPoolSizeInBytes)}"
)
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
@ -67,8 +34,6 @@ class AvesAppGlideModule : AppGlideModule() {
override fun isManifestParsingEnabled(): Boolean = false
companion object {
private val LOG_TAG = LogUtils.createTag<AvesAppGlideModule>()
// request a fresh image with the highest quality format
val uncachedFullImageOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import androidx.core.graphics.createBitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
@ -69,7 +68,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
bitmapWidth = width
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
}
val bitmap = createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
svg.renderToCanvas(canvas)

View file

@ -3,7 +3,6 @@ package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.graphics.scale
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
@ -83,9 +82,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
inSampleSize = sampleSize
}
try {
val bitmap: Bitmap? = TiffBitmapFactory.decodeFileDescriptor(fd, options)
// calling `TiffBitmapFactory.closeFd(fd)` after decoding yields a segmentation fault
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap == null) {
callback.onLoadFailed(Exception("Decoding full TIFF yielded null bitmap"))
} else if (customSize) {
@ -99,7 +96,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
dstWidth = width
dstHeight = (width / aspectRatio).toInt()
}
callback.onDataReady(bitmap.scale(dstWidth, dstHeight))
callback.onDataReady(Bitmap.createScaledBitmap(bitmap, dstWidth, dstHeight, true))
} else {
callback.onDataReady(bitmap)
}

View file

@ -2,7 +2,6 @@ package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
@ -21,6 +20,7 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope
@ -28,54 +28,45 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import kotlin.math.ceil
import kotlin.math.roundToInt
@GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(VideoThumbnail::class.java, Bitmap::class.java, VideoThumbnailLoader.Factory())
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
}
}
class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, Bitmap> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
}
override fun handles(model: VideoThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<VideoThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, Bitmap> = VideoThumbnailLoader()
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
override fun teardown() {}
}
}
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> {
internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
ioScope.launch {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever == null) {
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))
} else {
try {
var bitmap: Bitmap? = null
retriever.embeddedPicture?.let { bytes ->
try {
bitmap = BitmapFactory.decodeStream(ByteArrayInputStream(bytes))
} catch (e: IOException) {
// ignore
}
}
if (bitmap == null) {
var bytes = retriever.embeddedPicture
if (bytes == null) {
// there is no consistent strategy across devices to match
// the thumbnails returned by the content resolver / Media Store
// so we derive one in an arbitrary way
@ -120,9 +111,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
}
// the returned frame is already rotated according to the video metadata
bitmap = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val pixelCount = dstWidth * dstHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
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)
}
} else {
val pixelCount = videoWidth * videoHeight
val targetBitmapSizeBytes = BitmapUtils.getExpectedImageSize(pixelCount.toLong(), getPreferredConfig())
val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
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)
}
}
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
}
if (bitmap == null) {
callback.onLoadFailed(Exception("failed to get embedded picture or any frame for uri=${model.uri}"))
if (bytes != null) {
callback.onDataReady(ByteArrayInputStream(bytes))
} else {
callback.onDataReady(bitmap)
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
}
} catch (e: Exception) {
callback.onLoadFailed(e)
@ -161,14 +151,8 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
}
@RequiresApi(Build.VERSION_CODES.P)
private fun getBitmapParams(): MediaMetadataRetriever.BitmapParams {
val params = MediaMetadataRetriever.BitmapParams()
params.preferredConfig = this.getPreferredConfig()
return params
}
private fun getPreferredConfig(): Bitmap.Config {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
// for wide-gamut and HDR content which does not require alpha blending
Bitmap.Config.RGBA_1010102
@ -183,7 +167,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
companion object {
// same for either `ARGB_8888` or `RGBA_1010102`
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
}
}

View file

@ -32,7 +32,6 @@ 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.LocationInformationBox
import org.mp4parser.support.AbstractBox
import org.mp4parser.support.Matrix
import org.mp4parser.tools.Path
@ -46,15 +45,6 @@ object Mp4ParserHelper {
// arbitrary size to detect boxes that may yield an OOM
private const val BOX_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
const val SAMSUNG_MAKERNOTE_BOX_TYPE = "sefd"
const val SEFD_MOTION_PHOTO_NAME = "MotionPhoto_Data"
private val largerTypeWhitelist = listOf(
// HEIC motion photo may contain Samsung maker notes in `sefd` box,
// including a video larger than the danger threshold
SAMSUNG_MAKERNOTE_BOX_TYPE,
)
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
@ -143,35 +133,6 @@ object Mp4ParserHelper {
return false
}
// returns the offset and data of the Samsung maker notes box
fun getSamsungSefd(context: Context, uri: Uri): Pair<Long, ByteArray>? {
try {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
pfd.use {
FileInputStream(it.fileDescriptor).use { stream ->
stream.channel.use { channel ->
IsoFile(channel, metadataBoxParser()).use { isoFile ->
var offset = 0L
for (box in isoFile.boxes) {
if (box is UnknownBox && box.type == SAMSUNG_MAKERNOTE_BOX_TYPE) {
if (!box.isParsed) {
box.parseDetails()
}
return Pair(offset + 8, box.data.toByteArray()) // skip 8 bytes for box header
}
offset += box.size
}
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to read sefd box", e)
}
return null
}
// extensions
fun IsoFile.updateLocation(locationIso6709: String?) {
@ -311,18 +272,18 @@ object Mp4ParserHelper {
)
setBoxSkipper { type, size ->
if (skippedTypes.contains(type)) return@setBoxSkipper true
if (size > BOX_SIZE_DANGER_THRESHOLD && !largerTypeWhitelist.contains(type)) throw Exception("box (type=$type size=$size) is too large")
if (size > BOX_SIZE_DANGER_THRESHOLD) throw Exception("box (type=$type size=$size) is too large")
false
}
}
fun getUserDataBox(
fun getUserData(
context: Context,
mimeType: String,
uri: Uri,
): UserDataBox? {
if (mimeType != MimeTypes.MP4) return null
): MutableMap<String, String> {
val fields = HashMap<String, String>()
if (mimeType != MimeTypes.MP4) return fields
try {
// we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
@ -331,7 +292,10 @@ object Mp4ParserHelper {
stream.channel.use { channel ->
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, metadataBoxParser()).use { isoFile ->
return Path.getPath(isoFile.movieBox, UserDataBox.TYPE)
val userDataBox = Path.getPath<UserDataBox>(isoFile.movieBox, UserDataBox.TYPE)
if (userDataBox != null) {
fields.putAll(extractBoxFields(userDataBox))
}
}
}
}
@ -341,10 +305,10 @@ object Mp4ParserHelper {
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
}
return null
return fields
}
fun extractBoxFields(container: Container): HashMap<String, String> {
private fun extractBoxFields(container: Container): HashMap<String, String> {
val fields = HashMap<String, String>()
for (box in container.boxes) {
if (box is AbstractBox && !box.isParsed) {
@ -358,20 +322,9 @@ object Mp4ParserHelper {
is AppleGPSCoordinatesBox -> fields[key] = box.value
is AppleItemListBox -> fields.putAll(extractBoxFields(box))
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
is HandlerBox -> {}
is LocationInformationBox -> {
hashMapOf<String, String>(
"Language" to box.language,
"Name" to box.name,
"Role" to box.role.toString(),
"Longitude" to box.longitude.toString(),
"Latitude" to box.latitude.toString(),
"Altitude" to box.altitude.toString(),
"Astronomical Body" to box.astronomicalBody,
"Additional Notes" to box.additionalNotes,
).forEach { (k, v) -> fields["$key/$k"] = v }
}
is Utf8AppleDataBox -> fields[key] = box.value
is HandlerBox -> {}
is MetaBox -> {
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
@ -396,8 +349,6 @@ object Mp4ParserHelper {
}
}
is Utf8AppleDataBox -> fields[key] = box.value
else -> fields[key] = box.toString()
}
}
@ -410,7 +361,6 @@ object Mp4ParserHelper {
"catg" -> "Category"
"covr" -> "Cover Art"
"keyw" -> "Keyword"
"loci" -> "Location"
"mcvr" -> "Preview Image"
"pcst" -> "Podcast"
"SDLN" -> "Play Mode"

View file

@ -37,8 +37,6 @@ import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
object MultiPage {
private val LOG_TAG = LogUtils.createTag<MultiPage>()
// TODO TLAD more generic support, (e.g. 0x00000014 + `ftyp` + `qt `)
// atom length (variable, e.g. `0x00000018`) + atom type (`ftyp`) + type (variable, e.g. `mp42`, `qt`)
private val heicMotionPhotoVideoStartIndicator = byteArrayOf(0x00, 0x00, 0x00, 0x18) + "ftypmp42".toByteArray()
// page info
@ -86,26 +84,6 @@ object MultiPage {
return tracks
}
fun isHeicSefdMotionPhoto(context: Context, uri: Uri): Boolean {
return getHeicSefdMotionPhotoVideoSizing(context, uri) != null
}
private fun getHeicSefdMotionPhotoVideoSizing(context: Context, uri: Uri): Pair<Long, Long>? {
Mp4ParserHelper.getSamsungSefd(context, uri)?.let { (sefdOffset, sefdBytes) ->
// we could properly parse each tag until we find the "embedded video" tag (0x0a30)
// but it seems that decoding the SEFT trailer is necessary for this,
// so we simply search for the "MotionPhoto_Data" sequence instead
val name = Mp4ParserHelper.SEFD_MOTION_PHOTO_NAME
val index = sefdBytes.indexOfBytes(name.toByteArray(Charsets.UTF_8))
if (index != -1) {
val videoOffset = sefdOffset + index + name.length
val videoSize = sefdBytes.size - (videoOffset - sefdOffset)
return Pair(videoOffset, videoSize)
}
}
return null
}
private fun getJpegMpfPrimaryRotation(context: Context, uri: Uri, sizeBytes: Long): Int {
val mimeType = MimeTypes.JPEG
var rotationDegrees = 0
@ -267,38 +245,40 @@ object MultiPage {
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
val pages = ArrayList<FieldMap>()
getMotionPhotoVideoInfo(context, uri, mimeType, sizeBytes)?.let { videoInfo ->
// set the original image as the first and default track
var pageIndex = 0
pages.add(
hashMapOf(
KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to mimeType,
KEY_IS_DEFAULT to true,
)
)
// add video tracks from the appended video
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val page: FieldMap = hashMapOf(
getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
getTrailerVideoInfo(context, uri, fileSizeBytes = sizeBytes, videoSizeBytes = videoSizeBytes)?.let { videoInfo ->
// set the original image as the first and default track
var pageIndex = 0
pages.add(
hashMapOf(
KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false,
KEY_MIME_TYPE to mimeType,
KEY_IS_DEFAULT to true,
)
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
)
// add video tracks from the appended video
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val page: FieldMap = hashMapOf(
KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false,
)
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
}
videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
pages.add(page)
}
videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
pages.add(page)
}
}
}
return pages
}
fun getTrailerVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
fun getMotionPhotoVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
if (MimeTypes.isHeic(mimeType)) {
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
@ -345,35 +325,22 @@ object MultiPage {
return offsetFromEnd
}
private fun getMotionPhotoVideoInfo(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): MediaFormat? {
getMotionPhotoVideoSizing(context, uri, mimeType, sizeBytes)?.let { (videoOffset, videoSize) ->
return getEmbedVideoInfo(context, uri, videoOffset, videoSize)
}
return null
}
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSize: Long, videoSize: Long): MediaFormat? {
return getEmbedVideoInfo(context, uri, videoOffset = fileSize - videoSize, videoSize = videoSize)
}
private fun getEmbedVideoInfo(context: Context, uri: Uri, videoOffset: Long, videoSize: Long): MediaFormat? {
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSizeBytes: Long, videoSizeBytes: Long): MediaFormat? {
var format: MediaFormat? = null
val extractor = MediaExtractor()
var pfd: ParcelFileDescriptor? = null
try {
val videoStartOffset = fileSizeBytes - videoSizeBytes
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) {
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
if (extractor.trackCount > 0) {
// only consider the first track to represent the appended video
val trackIndex = 0
try {
val format = extractor.getTrackFormat(trackIndex)
format.getString(MediaFormat.KEY_MIME)?.let {
if (MimeTypes.isVideo(it)) {
return format
}
}
format = extractor.getTrackFormat(trackIndex)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$trackIndex", e)
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
}
}
}
@ -383,22 +350,7 @@ object MultiPage {
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
return format
}
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {

View file

@ -81,18 +81,17 @@ object PixyMetaHelper {
output: OutputStream,
iptcDataList: List<FieldMap>?,
) {
val iptc: List<IPTCDataSet> = iptcDataList?.flatMap {
val iptc = iptcDataList?.flatMap {
val record = it["record"] as Int
val tag = it["tag"] as Int
val values = it["values"] as List<*>
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
} ?: ArrayList()
} ?: ArrayList<IPTCDataSet>()
Metadata.insertIPTC(input, output, iptc)
}
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
// PixyMeta may fail with just a log, and write nothing to the output
fun setXmp(
input: InputStream,
output: OutputStream,

View file

@ -18,7 +18,7 @@ object EntryFields {
const val IS_FLIPPED = "isFlipped" // boolean
const val DATE_ADDED_SECS = "dateAddedSecs" // long
const val DATE_MODIFIED_MILLIS = "dateModifiedMillis" // long
const val DATE_MODIFIED_SECS = "dateModifiedSecs" // long
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
const val DURATION_MILLIS = "durationMillis" // long

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.core.net.toUri
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.jpeg.JpegDirectory
@ -30,6 +29,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class SourceEntry {
private val origin: Int
@ -42,7 +42,7 @@ class SourceEntry {
private var sourceRotationDegrees: Int? = null
private var sizeBytes: Long? = null
private var dateAddedSecs: Long? = null
private var dateModifiedMillis: Long? = null
private var dateModifiedSecs: Long? = null
private var sourceDateTakenMillis: Long? = null
private var durationMillis: Long? = null
@ -65,16 +65,16 @@ class SourceEntry {
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
title = map[EntryFields.TITLE] as String?
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
dateModifiedMillis = toLong(map[EntryFields.DATE_MODIFIED_MILLIS])
dateModifiedSecs = toLong(map[EntryFields.DATE_MODIFIED_SECS])
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
}
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedMillis: Long) {
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
this.path = path
this.title = title
this.sizeBytes = sizeBytes
this.dateModifiedMillis = dateModifiedMillis
this.dateModifiedSecs = dateModifiedSecs
}
fun toMap(): FieldMap {
@ -89,7 +89,7 @@ class SourceEntry {
EntryFields.SIZE_BYTES to sizeBytes,
EntryFields.TITLE to title,
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
EntryFields.DURATION_MILLIS to durationMillis,
// only for map export

View file

@ -46,7 +46,7 @@ internal class FileImageProvider : ImageProvider() {
path = path,
title = file.name,
sizeBytes = file.length(),
dateModifiedMillis = file.lastModified(),
dateModifiedSecs = file.lastModified() / 1000,
)
}
} catch (e: SecurityException) {
@ -91,7 +91,7 @@ internal class FileImageProvider : ImageProvider() {
return hashMapOf(
EntryFields.URI to Uri.fromFile(newFile).toString(),
EntryFields.PATH to newFile.path,
EntryFields.DATE_MODIFIED_MILLIS to newFile.lastModified(),
EntryFields.DATE_MODIFIED_SECS to newFile.lastModified() / 1000,
)
}
@ -99,7 +99,7 @@ internal class FileImageProvider : ImageProvider() {
try {
val file = File(path)
if (file.exists()) {
newFields[EntryFields.DATE_MODIFIED_MILLIS] = file.lastModified()
newFields[EntryFields.DATE_MODIFIED_SECS] = file.lastModified() / 1000
newFields[EntryFields.SIZE_BYTES] = file.length()
}
callback.onSuccess(newFields)

View file

@ -11,7 +11,6 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.commonsware.cwac.document.DocumentFileCompat
@ -33,7 +32,6 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.xmp.GoogleXMP
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictResolution
@ -65,6 +63,8 @@ import java.util.Date
import java.util.TimeZone
import kotlin.math.absoluteValue
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
import deckers.thibault.aves.model.EntryFields
abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
@ -142,18 +142,16 @@ abstract class ImageProvider {
val oldFile = File(sourcePath)
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
val defaultExtension = oldFile.extension
oldFile.parent?.let { dir ->
val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
defaultExtension = defaultExtension,
conflictStrategy = NameConflictStrategy.RENAME,
)
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}"
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val newFile = File(dir, targetFileName)
if (oldFile != newFile) {
newFields = renameSingle(
@ -279,17 +277,11 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
}
// there is no benefit providing input extension
// for known output MIME type
val defaultExtension = null
val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType,
defaultExtension = defaultExtension,
conflictStrategy = nameConflictStrategy,
)
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
@ -366,7 +358,6 @@ abstract class ImageProvider {
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = defaultExtension,
write = write,
)
@ -474,7 +465,6 @@ abstract class ImageProvider {
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = captureMimeType,
defaultExtension = null,
conflictStrategy = nameConflictStrategy,
)
} catch (e: Exception) {
@ -581,14 +571,13 @@ abstract class ImageProvider {
dir: String,
desiredNameWithoutExtension: String,
mimeType: String,
defaultExtension: String?,
conflictStrategy: NameConflictStrategy,
): NameConflictResolution {
val sanitizedNameWithoutExtension = sanitizeDesiredFileName(desiredNameWithoutExtension)
var resolvedName: String? = sanitizedNameWithoutExtension
var replacementFile: File? = null
val extension = extensionFor(mimeType, defaultExtension)
val extension = extensionFor(mimeType)
val targetFile = File(dir, "$sanitizedNameWithoutExtension$extension")
when (conflictStrategy) {
NameConflictStrategy.RENAME -> {
@ -659,13 +648,13 @@ abstract class ImageProvider {
val originalFileSize = File(path).length()
var trailerVideoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply {
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try {
if (trailerVideoSize != null && isTrailerVideoValid) {
if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - trailerVideoSize).toInt()
val videoByteSize = trailerVideoSize.toInt()
val imageSize = (originalFileSize - videoSize).toInt()
val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
@ -691,19 +680,18 @@ abstract class ImageProvider {
try {
edit(ExifInterface(editableFile))
if (editableFile.length() == 0L) {
callback.onFailure(Exception("editing Exif yielded an empty file"))
return false
}
val editedMimeType = detectMimeType(context, Uri.fromFile(editableFile), mimeType)
if (editedMimeType != mimeType) {
throw Exception("editing Exif changes mimeType=$mimeType -> $editedMimeType for uri=$uri path=$path")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// editing may corrupt the file for various reasons,
// 1) as of androidx.exifinterface:exifinterface:1.3.6, editing some specific WEBP
// makes them undecodable by some decoders (including Android's and Chrome's)
// even though `BitmapFactory` successfully decodes their bounds,
// so we check whether decoding it throws an exception
// 2) some users have reported corruption when editing JPEG as well,
// but conditions are unknown (specific image, custom ROM, low storage, race condition, etc.)
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
}
@ -745,13 +733,13 @@ abstract class ImageProvider {
val originalFileSize = File(path).length()
var trailerVideoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply {
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try {
if (trailerVideoSize != null && isTrailerVideoValid) {
if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - trailerVideoSize).toInt()
val videoByteSize = trailerVideoSize.toInt()
val imageSize = (originalFileSize - videoSize).toInt()
val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
@ -793,11 +781,6 @@ abstract class ImageProvider {
}
}
if (editableFile.length() == 0L) {
callback.onFailure(Exception("editing IPTC yielded an empty file"))
return false
}
if (trailerVideoBytes != null) {
// append trailer video, if any
editableFile.appendBytes(trailerVideoBytes!!)
@ -916,7 +899,7 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = StorageUtils.createTempFile(context).apply {
try {
editXmpWithPixy(
@ -934,16 +917,11 @@ abstract class ImageProvider {
}
}
if (editableFile.length() == 0L) {
callback.onFailure(Exception("editing XMP yielded an empty file"))
return false
}
try {
// copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false
}
editableFile.delete()
@ -1284,15 +1262,15 @@ abstract class ImageProvider {
callback: ImageOpCallback,
) {
val originalFileSize = File(path).length()
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
if (trailerVideoSize == null) {
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
if (videoSize == null) {
callback.onFailure(Exception("failed to get trailer video size"))
return
}
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSize = originalFileSize, videoSize = trailerVideoSize) != null
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSizeBytes = originalFileSize, videoSizeBytes = videoSize) != null
if (!isTrailerVideoValid) {
callback.onFailure(Exception("failed to open trailer video with size=$trailerVideoSize"))
callback.onFailure(Exception("failed to open trailer video with size=$videoSize"))
return
}
@ -1300,7 +1278,7 @@ abstract class ImageProvider {
try {
val inputStream = StorageUtils.openInputStream(context, uri)
// partial copy
transferFrom(inputStream, originalFileSize - trailerVideoSize)
transferFrom(inputStream, originalFileSize - videoSize)
} catch (e: Exception) {
Log.d(LOG_TAG, "failed to remove trailer video", e)
callback.onFailure(e)
@ -1335,8 +1313,8 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
val trailerVideoSize = MultiPage.getTrailerVideoSize(context, uri, mimeType, originalFileSize)
val isTrailerVideoValid = trailerVideoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, trailerVideoSize) != null
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
val editableFile = StorageUtils.createTempFile(context).apply {
try {
outputStream().use { output ->
@ -1352,16 +1330,11 @@ abstract class ImageProvider {
}
}
if (editableFile.length() == 0L) {
callback.onFailure(Exception("removing metadata yielded an empty file"))
return
}
try {
// copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoSize, editableFile, callback)) {
if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return
}
editableFile.delete()

View file

@ -51,14 +51,14 @@ import kotlin.coroutines.suspendCoroutine
class MediaStoreImageProvider : ImageProvider() {
fun fetchAll(
context: Context,
knownEntries: Map<Long?, Long?>,
knownEntries: Map<Long?, Int?>,
directory: String?,
handleNewEntry: NewEntryHandler,
) {
Log.d(LOG_TAG, "fetching all media store items for ${knownEntries.size} known entries, directory=$directory")
val isModified = fun(contentId: Long, dateModifiedMillis: Long): Boolean {
val isModified = fun(contentId: Long, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedMillis
return knownDate == null || knownDate < dateModifiedSecs
}
val handleNew: NewEntryHandler
var selection: String? = null
@ -96,7 +96,7 @@ class MediaStoreImageProvider : ImageProvider() {
var found = false
val fetched = arrayListOf<FieldMap>()
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) }
if (id != null) {
if (sourceMimeType == null || isImage(sourceMimeType)) {
@ -227,8 +227,8 @@ class MediaStoreImageProvider : ImageProvider() {
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val dateAddedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedSecsColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val dateTakenColumn = cursor.getColumnIndex(MediaColumns.DATE_TAKEN)
// image & video for API >=29, only for images for API <29
@ -240,8 +240,8 @@ class MediaStoreImageProvider : ImageProvider() {
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val dateModifiedMillis = cursor.getInt(dateModifiedSecsColumn) * 1000L
if (isValidEntry(id, dateModifiedMillis)) {
val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
if (isValidEntry(id, dateModifiedSecs)) {
// for multiple items, `contentUri` is the root without ID,
// but for single items, `contentUri` already contains the ID
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, id)
@ -255,18 +255,17 @@ class MediaStoreImageProvider : ImageProvider() {
if (mimeType == null) {
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
} else {
val path = cursor.getString(pathColumn)
var entryFields: FieldMap = hashMapOf(
var entryMap: FieldMap = hashMapOf(
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
EntryFields.URI to itemUri.toString(),
EntryFields.PATH to path,
EntryFields.PATH to cursor.getString(pathColumn),
EntryFields.SOURCE_MIME_TYPE to mimeType,
EntryFields.WIDTH to width,
EntryFields.HEIGHT to height,
EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedSecsColumn),
EntryFields.DATE_MODIFIED_MILLIS to dateModifiedMillis,
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedColumn),
EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
EntryFields.DURATION_MILLIS to durationMillis,
// only for map export
@ -286,8 +285,8 @@ class MediaStoreImageProvider : ImageProvider() {
if (outWidth > 0 && outHeight > 0) {
width = outWidth
height = outHeight
entryFields[EntryFields.WIDTH] = width
entryFields[EntryFields.HEIGHT] = height
entryMap[EntryFields.WIDTH] = width
entryMap[EntryFields.HEIGHT] = height
}
}
} catch (e: IOException) {
@ -303,13 +302,11 @@ class MediaStoreImageProvider : ImageProvider() {
// missing some attributes such as width, height, orientation.
// Also, the reported size of raw images is inconsistent across devices
// and Android versions (sometimes the raw size, sometimes the decoded size).
val entry = SourceEntry(entryFields).fillPreCatalogMetadata(context)
entryFields = entry.toMap()
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context)
entryMap = entry.toMap()
}
getFileModifiedDateMillis(path)?.let { entryFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
handleNewEntry(entryFields)
handleNewEntry(entryMap)
found = true
}
}
@ -557,7 +554,6 @@ class MediaStoreImageProvider : ImageProvider() {
toBin: Boolean,
): FieldMap {
val sourcePath = sourceFile?.path
val sourceExtension = sourceFile?.extension
val sourceDir = sourceFile?.parent?.let { ensureTrailingSeparator(it) }
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
// nothing to do unless it's a renamed copy
@ -570,7 +566,6 @@ class MediaStoreImageProvider : ImageProvider() {
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
defaultExtension = sourceExtension,
conflictStrategy = nameConflictStrategy,
)
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
@ -582,7 +577,6 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = sourceExtension,
) { output: OutputStream ->
try {
sourceDocFile.copyTo(output)
@ -618,13 +612,12 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
defaultExtension: String?,
write: (OutputStream) -> Unit,
): String {
if (StorageUtils.isInVault(activity, targetDir)) {
return insertByFile(
targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
write = write,
)
}
@ -634,7 +627,7 @@ class MediaStoreImageProvider : ImageProvider() {
return insertByMediaStore(
activity = activity,
targetDir = targetDir,
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType, defaultExtension)}",
targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}",
write = write,
)
}
@ -646,7 +639,6 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
defaultExtension = defaultExtension,
write = write,
)
}
@ -705,7 +697,6 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
defaultExtension: String?,
write: (OutputStream) -> Unit,
): String {
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
@ -714,22 +705,8 @@ class MediaStoreImageProvider : ImageProvider() {
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
var targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
var targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
// providing a display name and a MIME type does not guarantee
// that the created document will be backed by a file with a valid media extension,
// but having an extension is essential for media detection by Android,
// so we retry with a display name that includes the extension
if ((targetDocFile.extension == null || targetDocFile.extension.isEmpty() || targetDocFile.extension == "bin") && defaultExtension != null) {
if (targetDocFile.exists()) {
targetDocFile.delete()
}
val extension = if (defaultExtension.startsWith(".")) defaultExtension else ".$defaultExtension"
targetTreeFile = targetDirDocFile.createFile(mimeType, "$targetNameWithoutExtension$extension")
targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
}
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
try {
targetDocFile.openOutputStream().use(write)
@ -846,32 +823,18 @@ class MediaStoreImageProvider : ImageProvider() {
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 }
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
callback.onSuccess(newFields)
}
}
// try to fetch the modified date from the file,
// as it is more precise than the one from the Media Store
private fun getFileModifiedDateMillis(path: String?): Long? {
if (path != null) {
try {
return File(path).lastModified()
} catch (securityException: SecurityException) {
// ignore
}
}
return null
}
private fun scanObsoletePath(context: Context, uri: Uri, path: String, mimeType: String) {
val file = File(path)
val delayMillis = 500L
@ -955,9 +918,8 @@ class MediaStoreImageProvider : ImageProvider() {
EntryFields.PATH to path,
)
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields[EntryFields.DATE_ADDED_SECS] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_MILLIS] = cursor.getInt(it) * 1000 }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_SECS] = cursor.getInt(it) }
cursor.close()
getFileModifiedDateMillis(path)?.let { newFields[EntryFields.DATE_MODIFIED_MILLIS] = it }
return newFields
}
} catch (e: Exception) {
@ -1068,4 +1030,4 @@ object MediaColumns {
typealias NewEntryHandler = (entry: FieldMap) -> Unit
private typealias NewEntryChecker = (contentId: Long, dateModifiedMillis: Long) -> Boolean
private typealias NewEntryChecker = (contentId: Long, dateModifiedSecs: Int) -> Boolean

View file

@ -2,121 +2,27 @@ package deckers.thibault.aves.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ColorSpace
import android.os.Build
import android.util.Half
import android.util.Log
import androidx.annotation.RequiresApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
object BitmapUtils {
private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
private const val INITIAL_BUFFER_SIZE = 2 shl 17 // 256kB
// arbitrary size to detect buffer that may yield an OOM
private const val BUFFER_SIZE_DANGER_THRESHOLD = 3 * (1 shl 20) // MB
private val freeBaos = ArrayList<ByteArrayOutputStream>()
private val mutex = Mutex()
private const val INT_BYTE_SIZE = 4
private const val MAX_2_BITS_FLOAT = 0x3.toFloat()
private const val MAX_8_BITS_FLOAT = 0xff.toFloat()
private const val MAX_10_BITS_FLOAT = 0x3ff.toFloat()
private const val RAW_BYTES_TRAILER_LENGTH = INT_BYTE_SIZE * 2
// bytes per pixel with different bitmap config
private const val BPP_ALPHA_8 = 1
private const val BPP_RGB_565 = 2
private const val BPP_ARGB_8888 = 4
private const val BPP_RGBA_1010102 = 4
private const val BPP_RGBA_F16 = 8
private fun getBytePerPixel(config: Bitmap.Config?): Int {
return when (config) {
Bitmap.Config.ALPHA_8 -> BPP_ALPHA_8
Bitmap.Config.RGB_565 -> BPP_RGB_565
Bitmap.Config.ARGB_8888 -> BPP_ARGB_8888
else -> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && config == Bitmap.Config.RGBA_F16) {
BPP_RGBA_F16
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
BPP_RGBA_1010102
} else {
// default
BPP_ARGB_8888
}
}
}
}
fun getExpectedImageSize(pixelCount: Long, config: Bitmap.Config?): Long {
return pixelCount * getBytePerPixel(config)
}
fun getRawBytes(bitmap: Bitmap?, recycle: Boolean): ByteArray? {
bitmap ?: return null
val byteCount = bitmap.byteCount
val width = bitmap.width
val height = bitmap.height
val config = bitmap.config
val colorSpace = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) bitmap.colorSpace else null
if (!MemoryUtils.canAllocate(byteCount)) {
throw Exception("bitmap buffer is $byteCount bytes, which cannot be allocated to a new byte array")
}
try {
// `ByteBuffer` initial order is always `BIG_ENDIAN`
var bytes = ByteBuffer.allocate(byteCount + RAW_BYTES_TRAILER_LENGTH).apply {
bitmap.copyPixelsToBuffer(this)
}.array()
// do not access bitmap after recycling
if (recycle) bitmap.recycle()
// convert pixel format and color space, if necessary
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
colorSpace?.let { srcColorSpace ->
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
if (config == Bitmap.Config.ARGB_8888) {
if (srcColorSpace != dstColorSpace) {
argb8888ToArgb8888(bytes, connector, end = byteCount)
}
} else if (config == Bitmap.Config.RGBA_F16) {
rgbaf16ToArgb8888(bytes, connector, end = byteCount)
val newConfigByteCount = byteCount / (BPP_RGBA_F16 / BPP_ARGB_8888)
bytes = bytes.sliceArray(0..<newConfigByteCount + RAW_BYTES_TRAILER_LENGTH)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
rgba1010102ToArgb8888(bytes, connector, end = byteCount)
}
}
}
// append bitmap size for use by the caller to interpret the raw bytes
val trailerOffset = bytes.size - RAW_BYTES_TRAILER_LENGTH
bytes = ByteBuffer.wrap(bytes).apply {
position(trailerOffset)
putInt(width)
putInt(height)
}.array()
return bytes
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
}
return null
}
suspend fun getEncodedBytes(bitmap: Bitmap?, canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
bitmap ?: return null
const val ARGB_8888_BYTE_SIZE = 4
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream
mutex.withLock {
// this method is called a lot, so we try and reuse output streams
@ -128,17 +34,19 @@ object BitmapUtils {
}
}
try {
// the Bitmap raw bytes are not decodable by Flutter
// we need to format them (compress, or add a BMP header) before sending them
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
// the BMP format allows an alpha channel, but Android decoding seems to ignore it
if (canHaveAlpha && bitmap.hasAlpha()) {
bitmap.compress(Bitmap.CompressFormat.PNG, quality, stream)
if (canHaveAlpha && hasAlpha()) {
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream)
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
if (recycle) bitmap.recycle()
if (recycle) this.recycle()
val bufferSize = stream.size()
if (!MemoryUtils.canAllocate(bufferSize)) {
if (bufferSize > BUFFER_SIZE_DANGER_THRESHOLD && !MemoryUtils.canAllocate(bufferSize)) {
throw Exception("bitmap compressed to $bufferSize bytes, which cannot be allocated to a new byte array")
}
@ -154,107 +62,6 @@ object BitmapUtils {
return null
}
// convert bytes, without reallocation:
// - from original color space to sRGB.
@RequiresApi(Build.VERSION_CODES.O)
private fun argb8888ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
// unpacking from ARGB_8888 and packing to ARGB_8888
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
for (i in start..<end step BPP_ARGB_8888) {
// mask with `0xff` to yield values in [0, 255], instead of [-128, 127]
val iB = bytes[i + 2].toInt() and 0xff
val iG = bytes[i + 1].toInt() and 0xff
val iR = bytes[i].toInt() and 0xff
// components as floats in sRGB
val srgbFloats = connector.transform(iR / MAX_8_BITS_FLOAT, iG / MAX_8_BITS_FLOAT, iB / MAX_8_BITS_FLOAT)
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
// keep alpha as it is, in `bytes[i + 3]`
bytes[i + 2] = srgbB.toByte()
bytes[i + 1] = srgbG.toByte()
bytes[i] = srgbR.toByte()
}
}
// convert bytes, without reallocation:
// - from config RGBA_F16 to ARGB_8888,
// - from original color space to sRGB.
@RequiresApi(Build.VERSION_CODES.O)
private fun rgbaf16ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
val indexDivider = BPP_RGBA_F16 / BPP_ARGB_8888
for (i in start..<end step BPP_RGBA_F16) {
// unpacking from RGBA_F16
// stored as [7,6,5,4,3,2,1,0] -> [AAAAAAAA AAAAAAAA BBBBBBBB BBBBBBBB GGGGGGGG GGGGGGGG RRRRRRRR RRRRRRRR]
val i7 = bytes[i + 7].toInt()
val i6 = bytes[i + 6].toInt()
val i5 = bytes[i + 5].toInt()
val i4 = bytes[i + 4].toInt()
val i3 = bytes[i + 3].toInt()
val i2 = bytes[i + 2].toInt()
val i1 = bytes[i + 1].toInt()
val i0 = bytes[i].toInt()
val hA = Half((((i7 and 0xff) shl 8) or (i6 and 0xff)).toShort())
val hB = Half((((i5 and 0xff) shl 8) or (i4 and 0xff)).toShort())
val hG = Half((((i3 and 0xff) shl 8) or (i2 and 0xff)).toShort())
val hR = Half((((i1 and 0xff) shl 8) or (i0 and 0xff)).toShort())
// components as floats in sRGB
val srgbFloats = connector.transform(hR.toFloat(), hG.toFloat(), hB.toFloat())
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
val alpha = (hA.toFloat() * 255.0f + 0.5f).toInt()
// packing to ARGB_8888
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
val dstI = i / indexDivider
bytes[dstI + 3] = alpha.toByte()
bytes[dstI + 2] = srgbB.toByte()
bytes[dstI + 1] = srgbG.toByte()
bytes[dstI] = srgbR.toByte()
}
}
// convert bytes, without reallocation:
// - from config RGBA_1010102 to ARGB_8888,
// - from original color space to sRGB.
@RequiresApi(Build.VERSION_CODES.O)
private fun rgba1010102ToArgb8888(bytes: ByteArray, connector: ColorSpace.Connector, start: Int = 0, end: Int = bytes.size) {
val alphaFactor = 255.0f / MAX_2_BITS_FLOAT
for (i in start..<end step BPP_RGBA_1010102) {
// unpacking from RGBA_1010102
// stored as [3,2,1,0] -> [AABBBBBB BBBBGGGG GGGGGGRR RRRRRRRR]
val i3 = bytes[i + 3].toInt()
val i2 = bytes[i + 2].toInt()
val i1 = bytes[i + 1].toInt()
val i0 = bytes[i].toInt()
val iA = ((i3 and 0xc0) shr 6)
val iB = ((i3 and 0x3f) shl 4) or ((i2 and 0xf0) shr 4)
val iG = ((i2 and 0x0f) shl 6) or ((i1 and 0xfc) shr 2)
val iR = ((i1 and 0x03) shl 8) or ((i0 and 0xff) shr 0)
// components as floats in sRGB
val srgbFloats = connector.transform(iR / MAX_10_BITS_FLOAT, iG / MAX_10_BITS_FLOAT, iB / MAX_10_BITS_FLOAT)
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
val alpha = (iA * alphaFactor + 0.5f).toInt()
// packing to ARGB_8888
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
bytes[i + 3] = alpha.toByte()
bytes[i + 2] = srgbB.toByte()
bytes[i + 1] = srgbG.toByte()
bytes[i] = srgbR.toByte()
}
}
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap

View file

@ -90,7 +90,12 @@ object BmpWriter {
var column = 0
while (column < biWidth) {
// non-premultiplied ARGB values in the sRGB color space
/*
alpha: (value shr 24 and 0xFF).toByte()
red: (value shr 16 and 0xFF).toByte()
green: (value shr 8 and 0xFF).toByte()
blue: (value and 0xFF).toByte()
*/
value = pixels[column]
// blue: [0], green: [1], red: [2]
rgb[0] = (value and 0xFF).toByte()

View file

@ -8,8 +8,6 @@ fun ByteBuffer.toByteArray(): ByteArray {
return bytes
}
fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
fun Byte.toHex(): String = "%02x".format(this)

View file

@ -20,7 +20,6 @@ fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
}
// Boyer-Moore algorithm for pattern searching
// Returns: an index of the first occurrence of the pattern or -1 if none is found.
fun ByteArray.indexOfBytes(pattern: ByteArray, start: Int = 0): Int {
val n: Int = this.size
val m: Int = pattern.size

View file

@ -5,5 +5,5 @@ import kotlin.math.pow
object MathUtils {
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()
}

View file

@ -84,11 +84,11 @@ object MimeTypes {
else -> false
}
// as of Flutter v3.16.4, with additional custom handling for SVG in Dart,
// while handling still PNG and JPEG on Android for color space and config conversion
fun canDecodeWithFlutter(mimeType: String, isAnimated: Boolean) = when (mimeType) {
// as of Flutter v3.16.4, with additional custom handling for SVG
fun canDecodeWithFlutter(mimeType: String, pageId: Int?, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
GIF, WEBP, BMP, WBMP, ICO, SVG -> true
JPEG, PNG -> isAnimated
JPEG -> (pageId ?: 0) == 0
PNG -> (rotationDegrees ?: 0) == 0 && !(isFlipped ?: false)
else -> false
}
@ -163,24 +163,12 @@ object MimeTypes {
// among other refs:
// - 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"
DNG, DNG_ADOBE -> ".dng"
HEIC, HEIF -> ".heif"
MP2T, MP2TS -> ".m2ts"
PSD_VND, PSD_X -> ".psd"
else -> {
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"
}
}
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
}
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)

View file

@ -15,8 +15,6 @@ import android.provider.DocumentsContract
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.utils.FileUtils.transferFrom
@ -31,6 +29,8 @@ import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import java.util.regex.Pattern
import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>()

View file

@ -8,5 +8,4 @@
<string name="analysis_channel_name">Exploració de mitjans</string>
<string name="analysis_notification_default_title">Explorant mitjans</string>
<string name="analysis_notification_action_stop">Atura</string>
<string name="map_shortcut_short_label">Mapa</string>
</resources>
</resources>

View file

@ -8,5 +8,4 @@
<string name="analysis_notification_default_title">Prohledávání médií</string>
<string name="analysis_notification_action_stop">Zastavit</string>
<string name="app_widget_label">Fotorámeček</string>
<string name="map_shortcut_short_label">Mapa</string>
</resources>
</resources>

View file

@ -8,5 +8,4 @@
<string name="analysis_channel_name">Σάρωση πολυμέσων</string>
<string name="analysis_notification_default_title">Σάρωση στοιχείων</string>
<string name="analysis_notification_action_stop">Διακοπή</string>
<string name="map_shortcut_short_label">Χάρτης</string>
</resources>
</resources>

View file

@ -8,5 +8,4 @@
<string name="analysis_notification_action_stop">توقف کردن</string>
<string name="app_widget_label">قاب عکس</string>
<string name="app_name">Aves</string>
<string name="map_shortcut_short_label">نقشه</string>
</resources>
</resources>

View file

@ -5,8 +5,7 @@
<string name="wallpaper">Fondo da pantalla</string>
<string name="search_shortcut_short_label">Procura</string>
<string name="videos_shortcut_short_label">Vídeos</string>
<string name="analysis_channel_name">Escanear medios</string>
<string name="analysis_channel_name">Escaneo multimedia</string>
<string name="analysis_notification_default_title">Escaneando medios</string>
<string name="analysis_notification_action_stop">Pare</string>
<string name="map_shortcut_short_label">Mapa</string>
</resources>
</resources>

View file

@ -9,4 +9,4 @@
<string name="analysis_channel_name">Skönnun myndefnis</string>
<string name="search_shortcut_short_label">Leita</string>
<string name="map_shortcut_short_label">Landakort</string>
</resources>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="app_name">אייבז</string>
<string name="app_widget_label">מסגרת תמונה</string>
<string name="wallpaper">טפט</string>
<string name="search_shortcut_short_label">חיפוש</string>
@ -9,4 +9,4 @@
<string name="analysis_notification_default_title">סורק מדיה</string>
<string name="analysis_notification_action_stop">הפסק</string>
<string name="map_shortcut_short_label">מפה</string>
</resources>
</resources>

View file

@ -9,4 +9,4 @@
<string name="analysis_notification_default_title">メディアをスキャン中</string>
<string name="analysis_notification_action_stop">停止</string>
<string name="map_shortcut_short_label">マップ</string>
</resources>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ಎವೀಸ್</string>
<string name="app_name">Aves</string>
<string name="app_widget_label">ಫೋಟೋ ಫ್ರೇಮ್</string>
<string name="wallpaper">ವಾಲ್ಪೇಪರ್</string>
<string name="videos_shortcut_short_label">ವೀಡಿಯೊಗಳು</string>
@ -8,5 +8,4 @@
<string name="analysis_notification_default_title">ಮೀಡಿಯಾ ಸ್ಕ್ಯಾನ್ ಮಾಡಲಾಗುತ್ತಿದೆ</string>
<string name="analysis_notification_action_stop">ನಿಲ್ಲಿಸಿ</string>
<string name="search_shortcut_short_label">ಹುಡುಕಿ</string>
<string name="map_shortcut_short_label">ನಕ್ಷೆ</string>
</resources>
</resources>

View file

@ -8,5 +8,4 @@
<string name="videos_shortcut_short_label">ဗီဒီယိုများ</string>
<string name="analysis_notification_default_title">မီဒီယာ ကိုစကင်ဖတ်နေသည်</string>
<string name="analysis_notification_action_stop">ရပ်ရန်</string>
<string name="map_shortcut_short_label">မြေပုံ</string>
</resources>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">एभस</string>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="search_shortcut_short_label">ସନ୍ଧାନ</string>
</resources>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
</resources>

View file

@ -8,5 +8,4 @@
<string name="search_shortcut_short_label">搜尋</string>
<string name="analysis_channel_name">媒體掃描</string>
<string name="analysis_notification_action_stop">停止</string>
<string name="map_shortcut_short_label">地圖</string>
</resources>
</resources>

View file

@ -22,6 +22,7 @@ import static androidx.exifinterface.media.ExifInterfaceUtilsFork.convertToLongA
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.copy;
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.parseSubSeconds;
import static androidx.exifinterface.media.ExifInterfaceUtilsFork.startsWith;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.nio.ByteOrder.BIG_ENDIAN;
import static java.nio.ByteOrder.LITTLE_ENDIAN;
@ -90,7 +91,7 @@ import java.util.regex.Pattern;
import java.util.zip.CRC32;
/*
* Forked from 'androidx.exifinterface:exifinterface:1.4.1'
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-beta01' on 2025/01/21
* Named differently to let ExifInterface be loaded as subdependency.
* cf https://maven.google.com/web/index.html?q=exifinterface#androidx.exifinterface:exifinterface
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
@ -138,12 +139,6 @@ public class ExifInterfaceFork {
// TLAD threshold for safer Exif attribute parsing
private static final int ATTRIBUTE_SIZE_DANGER_THRESHOLD = 3 * (1 << 20); // MB
// TLAD available heap size, to check allocations
private long getAvailableHeapSize() {
final Runtime runtime = Runtime.getRuntime();
return runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory());
}
private static final String TAG = "ExifInterface";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
@ -4558,7 +4553,7 @@ public class ExifInterfaceFork {
&& (mXmpFromSeparateMarker != null || !containsTiff700Xmp))
|| (xmpHandling == XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
&& !containsTiff700Xmp)) {
mXmpFromSeparateMarker = value != null ? ExifAttribute.createByte(value) : null;
mXmpFromSeparateMarker = ExifAttribute.createByte(value);
return;
}
}
@ -6563,9 +6558,8 @@ public class ExifInterfaceFork {
// Exif data in WebP images (e.g.
// https://github.com/ImageMagick/ImageMagick/issues/3140)
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
payload =
Arrays.copyOfRange(
payload, IDENTIFIER_EXIF_APP1.length, payload.length);
payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
payload.length);
}
// Save offset to EXIF data for handling thumbnail and attribute offsets.
@ -6728,11 +6722,8 @@ public class ExifInterfaceFork {
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
boolean needToWriteExif = true;
// Either there's some XMP data to write, or it has been cleared locally but was present in
// the file when it was read (and so needs to be removed).
boolean needToHandleXmpChunk =
mXmpFromSeparateMarker != null || mFileOnDiskContainsSeparateXmpMarker;
while (needToWriteExif || needToHandleXmpChunk) {
boolean needToWriteXmp = mXmpFromSeparateMarker != null;
while (needToWriteExif || needToWriteXmp) {
int chunkLength = dataInputStream.readInt();
int chunkType = dataInputStream.readInt();
if (chunkType == PNG_CHUNK_TYPE_IHDR) {
@ -6747,7 +6738,7 @@ public class ExifInterfaceFork {
}
if (mXmpFromSeparateMarker != null && !mFileOnDiskContainsSeparateXmpMarker) {
writePngXmpItxtChunk(dataOutputStream);
needToHandleXmpChunk = false;
needToWriteXmp = false;
}
continue;
} else if (chunkType == PNG_CHUNK_TYPE_EXIF && needToWriteExif) {
@ -6755,25 +6746,10 @@ public class ExifInterfaceFork {
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
needToWriteExif = false;
continue;
} else if (chunkType == PNG_CHUNK_TYPE_ITXT
&& chunkLength >= PNG_ITXT_XMP_KEYWORD.length) {
// Read the 17 byte keyword and 5 expected null bytes.
byte[] keyword = new byte[PNG_ITXT_XMP_KEYWORD.length];
dataInputStream.readFully(keyword);
int remainingChunkBytes = chunkLength - keyword.length + PNG_CHUNK_CRC_BYTE_LENGTH;
if (Arrays.equals(keyword, PNG_ITXT_XMP_KEYWORD)) {
if (mXmpFromSeparateMarker != null) {
writePngXmpItxtChunk(dataOutputStream);
}
dataInputStream.skipFully(remainingChunkBytes);
needToHandleXmpChunk = false;
} else {
// This is a non-XMP iTXt chunk, so just copy it to the output and continue.
dataOutputStream.writeInt(chunkLength);
dataOutputStream.writeInt(chunkType);
dataOutputStream.write(keyword);
copy(dataInputStream, dataOutputStream, remainingChunkBytes);
}
} else if (chunkType == PNG_CHUNK_TYPE_ITXT && needToWriteXmp) {
writePngXmpItxtChunk(dataOutputStream);
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
needToWriteXmp = false;
continue;
}
dataOutputStream.writeInt(chunkLength);
@ -7560,13 +7536,6 @@ public class ExifInterfaceFork {
Log.d(TAG, "Invalid strip offset value");
return;
}
// TLAD start
if (bytesToSkip > getAvailableHeapSize()) {
throw new IOException("cannot allocate " + bytesToSkip + " bytes to skip to retrieve thumbnail");
}
// TLAD end
try {
in.skipFully(bytesToSkip);
} catch (EOFException e) {

View file

@ -31,7 +31,7 @@ import java.io.InputStream;
import java.io.OutputStream;
/*
* Forked from 'androidx.exifinterface:exifinterface:1.4.1'
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-alpha01' on 2024/11/17
* Named differently to let ExifInterface be loaded as subdependency.
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
*/

View file

@ -1,10 +1,21 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.nonTransitiveRClass=false
android.nonFinalResIds=false
# full mode is too aggressive and removes essential code
# of `metadata-extractor` even when adding `-keep class com.drew.**{ *; }`
android.enableR8.fullMode=false

33
android/settings.gradle Normal file
View file

@ -0,0 +1,33 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
settings.ext.kotlin_version = '2.1.10'
settings.ext.ksp_version = "$kotlin_version-1.0.29"
settings.ext.agp_version = '8.8.0'
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version("1.0.0")
id("com.android.application") version("$agp_version") apply(false)
id("org.jetbrains.kotlin.android") version("$kotlin_version") apply(false)
id("com.google.devtools.ksp") version("$ksp_version") apply(false)
id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0")
}
include(":app")
include(":exifinterface")

View file

@ -1,28 +0,0 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.10.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.21" apply false
id("com.google.devtools.ksp") version "2.1.21-2.0.1" apply false
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":app")
include(":exifinterface")

View file

@ -1,35 +0,0 @@
Terms of Service
================
“Aves Gallery” is an open-source gallery and metadata explorer app allowing you to access and manage your local photos and videos.
The app is designed for legal, authorized and acceptable purposes.
Disclaimer
==========
The app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
Privacy Policy
==============
The app does not collect any personal data. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.
Optionally, with your consent, the app accesses the inventory of installed apps to improve album display.
Optionally, with your consent, the app collects anonymous error and diagnostic data to improve the app quality. We use Firebase Crashlytics, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data.
Contact
=======
Developer: Thibault Deckers
Email: gallery.aves@gmail.com
Website: https://github.com/deckerst/aves

View file

@ -1,4 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- provider: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 KiB

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 555 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Some files were not shown because too many files have changed in this diff Show more